4545from ctrlutil import unwrap
4646from freqplot import default_frequency_range
4747
48- def nichols (syslist , omega = None ):
48+ # Nichols plot
49+ def nichols (syslist , omega = None , grid = True ):
4950 """Nichols plot for a system
5051
5152 Usage
@@ -60,6 +61,8 @@ def nichols(syslist, omega=None):
6061 List of linear input/output systems (single system is OK)
6162 omega : freq_range
6263 Range of frequencies (list or bounds) in rad/sec
64+ grid : boolean, optional
65+ True if the plot should include a Nichols-chart grid. Default is True.
6366
6467 Return values
6568 -------------
@@ -71,7 +74,7 @@ def nichols(syslist, omega=None):
7174 syslist = (syslist ,)
7275
7376 # Select a default range if none is provided
74- if ( omega == None ) :
77+ if omega is None :
7578 omega = default_frequency_range (syslist )
7679
7780 for sys in syslist :
@@ -94,88 +97,110 @@ def nichols(syslist, omega=None):
9497
9598 # Mark the -180 point
9699 plt .plot ([- 180 ], [0 ], 'r+' )
100+
101+ # Add grid
102+ if grid :
103+ nichols_grid ()
97104
98105# Nichols grid
99- def nichols_grid ():
106+ #! TODO: Consider making linestyle configurable
107+ def nichols_grid (cl_mags = None , cl_phases = None ):
100108 """Nichols chart grid
101109
102110 Usage
103111 =====
104112 nichols_grid()
105113
106- Plots a Nichols chart grid on the current axis.
114+ Plots a Nichols chart grid on the current axis, or creates a new chart
115+ if no plot already exists.
107116
108117 Parameters
109118 ----------
110- None
119+ cl_mags : array-like (dB), optional
120+ Array of closed-loop magnitudes defining the iso-gain lines on a
121+ custom Nichols chart.
122+ cl_phases : array-like (degrees), optional
123+ Array of closed-loop phases defining the iso-phase lines on a custom
124+ Nichols chart. Must be in the range -360 < cl_phases < 0
111125
112126 Return values
113127 -------------
114128 None
115129 """
116- mag_min_default = - 40.0 # dB
117- mag_step = 20.0 # dB
118-
119- # Chart defaults
120- phase_min , phase_max , mag_min , mag_max = - 360.0 , 0.0 , mag_min_default , 40 .0
121-
122- # Set actual chart bounds based on current plot
130+ # Default chart size
131+ ol_phase_min = - 359.99
132+ ol_phase_max = 0.0
133+ ol_mag_min = - 40.0
134+ ol_mag_max = default_ol_mag_max = 50 .0
135+
136+ # Find bounds of the current dataset, if there is one.
123137 if plt .gcf ().gca ().has_data ():
124- phase_min , phase_max , mag_min , mag_max = plt .axis ()
125-
138+ ol_phase_min , ol_phase_max , ol_mag_min , ol_mag_max = plt .axis ()
139+
126140 # M-circle magnitudes.
127- # The "fixed" set are always generated, since this guarantees a recognizable
128- # Nichols chart grid.
129- mags_fixed = np . array ([ - 40.0 , - 20.0 , - 12.0 , - 6.0 , - 3.0 , - 1.0 , - 0.5 , 0.0 ,
130- 0.25 , 0.5 , 1.0 , 3.0 , 6.0 , 12.0 ])
131-
132- if mag_min < mag_min_default :
133- # Outside the "fixed" set of magnitudes, the generated M-circles
134- # are extended in steps of 'mag_step' dB to cover anything made
135- # visible by the range of the existing plot
136- mags_adjust = np .arange ( mag_step * np . floor ( mag_min / mag_step ),
137- mag_min_default , mag_step )
138- mags = np .concatenate (( mags_adjust , mags_fixed ))
139- else :
140- mags = mags_fixed
141+ if cl_mags is None :
142+ # Default chart magnitudes
143+ # The key set of magnitudes are always generated, since this
144+ # guarantees a recognizable Nichols chart grid.
145+ key_cl_mags = np . array ([ - 40.0 , - 20.0 , - 12.0 , - 6.0 , - 3.0 , - 1.0 , - 0.5 , 0.0 ,
146+ 0.25 , 0.5 , 1.0 , 3.0 , 6.0 , 12.0 ])
147+ # Extend the range of magnitudes if necessary. The extended arange
148+ # will end up empty if no extension is required. Assumes that closed-loop
149+ # magnitudes are approximately aligned with open-loop magnitudes beyond
150+ # the value of np.min(key_cl_mags)
151+ cl_mag_step = - 20.0 # dB
152+ extended_cl_mags = np .arange ( np . min ( key_cl_mags ),
153+ ol_mag_min + cl_mag_step , cl_mag_step )
154+ cl_mags = np . concatenate (( extended_cl_mags , key_cl_mags ))
141155
142156 # N-circle phases (should be in the range -360 to 0)
143- phases = np .array ([- 0.25 , - 10.0 , - 20.0 , - 30.0 , - 45.0 , - 60.0 , - 90.0 ,
144- - 120.0 , - 150.0 , - 180.0 , - 210.0 , - 240.0 , - 270.0 ,
145- - 310.0 , - 325.0 , - 340.0 , - 350.0 , - 359.75 ])
157+ if cl_phases is None :
158+ # Choose a reasonable set of default phases (denser if the open-loop
159+ # data is restricted to a relatively small range of phases).
160+ key_cl_phases = np .array ([- 0.25 , - 45.0 , - 90.0 , - 180.0 , - 270.0 , - 325.0 , - 359.75 ])
161+ if np .abs (ol_phase_max - ol_phase_min ) < 90.0 :
162+ other_cl_phases = np .arange (- 10.0 , - 360.0 , - 10.0 )
163+ else :
164+ other_cl_phases = np .arange (- 10.0 , - 360.0 , - 20.0 )
165+ cl_phases = np .concatenate ((key_cl_phases , other_cl_phases ))
166+ else :
167+ assert ((- 360.0 < np .min (cl_phases )) and (np .max (cl_phases ) < 0.0 ))
146168
147169 # Find the M-contours
148- m = m_circles (mags , phase_min = np .min (phases ), phase_max = np .max (phases ))
170+ m = m_circles (cl_mags , phase_min = np .min (cl_phases ), phase_max = np .max (cl_phases ))
149171 m_mag = 20 * sp .log10 (np .abs (m ))
150172 m_phase = sp .mod (sp .degrees (sp .angle (m )), - 360.0 ) # Unwrap
151173
152174 # Find the N-contours
153- n = n_circles (phases , mag_min = np .min (mags ), mag_max = np .max (mags ))
175+ n = n_circles (cl_phases , mag_min = np .min (cl_mags ), mag_max = np .max (cl_mags ))
154176 n_mag = 20 * sp .log10 (np .abs (n ))
155177 n_phase = sp .mod (sp .degrees (sp .angle (n )), - 360.0 ) # Unwrap
156178
157179 # Plot the contours behind other plot elements.
158180 # The "phase offset" is used to produce copies of the chart that cover
159181 # the entire range of the plotted data, starting from a base chart computed
160- # over the range -360 < phase < 0 (see above) . Given the range
182+ # over the range -360 < phase < 0. Given the range
161183 # the base chart is computed over, the phase offset should be 0
162- # for -360 < phase_min < 0.
163- phase_offset_min = 360.0 * np .ceil (phase_min / 360.0 )
164- phase_offset_max = 360.0 * np .ceil (phase_max / 360.0 ) + 360.0
184+ # for -360 < ol_phase_min < 0.
185+ phase_offset_min = 360.0 * np .ceil (ol_phase_min / 360.0 )
186+ phase_offset_max = 360.0 * np .ceil (ol_phase_max / 360.0 ) + 360.0
165187 phase_offsets = np .arange (phase_offset_min , phase_offset_max , 360.0 )
188+
166189 for phase_offset in phase_offsets :
190+ # Draw M and N contours
167191 plt .plot (m_phase + phase_offset , m_mag , color = 'gray' ,
168- linestyle = 'dashed ' , zorder = 0 )
192+ linestyle = 'dotted ' , zorder = 0 )
169193 plt .plot (n_phase + phase_offset , n_mag , color = 'gray' ,
170- linestyle = 'dashed ' , zorder = 0 )
194+ linestyle = 'dotted ' , zorder = 0 )
171195
172- # Add magnitude labels
173- for x , y , m in zip (m_phase [:][- 1 ], m_mag [:][- 1 ], mags ):
174- align = 'right' if m < 0.0 else 'left'
175- plt .text (x , y , str (m ) + ' dB' , size = 'small' , ha = align )
196+ # Add magnitude labels
197+ for x , y , m in zip (m_phase [:][- 1 ] + phase_offset , m_mag [:][- 1 ], cl_mags ):
198+ align = 'right' if m < 0.0 else 'left'
199+ plt .text (x , y , str (m ) + ' dB' , size = 'small' , ha = align , color = 'gray' )
176200
177- # Make sure axes conform to any pre-existing plot.
178- plt .axis ([phase_min , phase_max , mag_min , mag_max ])
201+ # Fit axes to generated chart
202+ plt .axis ([phase_offset_min - 360.0 , phase_offset_max - 360.0 ,
203+ np .min (cl_mags ), np .max ([ol_mag_max , default_ol_mag_max ])])
179204
180205#
181206# Utility functions
@@ -185,38 +210,44 @@ def nichols_grid():
185210#
186211
187212# Compute contours of a closed-loop transfer function
188- def closed_loop_contours (Hmag , Hphase ):
189- """Contours of the function H = G/(1+G).
213+ def closed_loop_contours (Gcl_mags , Gcl_phases ):
214+ """Contours of the function Gcl = Gol/(1+Gol), where
215+ Gol is an open-loop transfer function, and Gcl is a corresponding
216+ closed-loop transfer function.
190217
191218 Usage
192219 =====
193- contours = closed_loop_contours(mags, phases )
220+ contours = closed_loop_contours(Gcl_mags, Gcl_phases )
194221
195222 Parameters
196223 ----------
197- mags : array-like
198- Meshgrid array of magnitudes of the contours
199- phases : array-like
200- Meshgrid array of phases in radians of the contours
224+ Gcl_mags : array-like
225+ Array of magnitudes of the contours
226+ Gcl_phases : array-like
227+ Array of phases in radians of the contours
201228
202229 Return values
203230 -------------
204231 contours : complex array
205232 Array of complex numbers corresponding to the contours.
206233 """
207- # Compute the contours in H-space
208- H = Hmag * sp .exp (1.j * Hphase )
234+ # Compute the contours in Gcl-space. Since we're given closed-loop
235+ # magnitudes and phases, this is just a case of converting them into
236+ # a complex number.
237+ Gcl = Gcl_mags * sp .exp (1.j * Gcl_phases )
209238
210- # Invert H = G /(1+G ) to get an expression for the contours in G- space
211- return H / (1.0 - H )
239+ # Invert Gcl = Gol /(1+Gol ) to map the contours into the open-loop space
240+ return Gcl / (1.0 - Gcl )
212241
213242# M-circle
214243def m_circles (mags , phase_min = - 359.75 , phase_max = - 0.25 ):
215- """Constant-magnitude contours of the function H = G/(1+G).
244+ """Constant-magnitude contours of the function Gcl = Gol/(1+Gol), where
245+ Gol is an open-loop transfer function, and Gcl is a corresponding
246+ closed-loop transfer function.
216247
217248 Usage
218249 =====
219- contours = m_circles(mags)
250+ contours = m_circles(mags, phase_min, phase_max )
220251
221252 Parameters
222253 ----------
@@ -234,17 +265,19 @@ def m_circles(mags, phase_min=-359.75, phase_max=-0.25):
234265 """
235266 # Convert magnitudes and phase range into a grid suitable for
236267 # building contours
237- phases = sp .radians (sp .linspace (phase_min , phase_max , 500 ))
238- Hmag , Hphase = sp .meshgrid (10.0 ** (mags / 20.0 ), phases )
239- return closed_loop_contours (Hmag , Hphase )
268+ phases = sp .radians (sp .linspace (phase_min , phase_max , 2000 ))
269+ Gcl_mags , Gcl_phases = sp .meshgrid (10.0 ** (mags / 20.0 ), phases )
270+ return closed_loop_contours (Gcl_mags , Gcl_phases )
240271
241272# N-circle
242273def n_circles (phases , mag_min = - 40.0 , mag_max = 12.0 ):
243- """Constant-phase contours of the function H = G/(1+G).
274+ """Constant-phase contours of the function Gcl = Gol/(1+Gol), where
275+ Gol is an open-loop transfer function, and Gcl is a corresponding
276+ closed-loop transfer function.
244277
245278 Usage
246279 =====
247- contour = n_circles(angles )
280+ contours = n_circles(phases, mag_min, mag_max )
248281
249282 Parameters
250283 ----------
@@ -263,5 +296,5 @@ def n_circles(phases, mag_min=-40.0, mag_max=12.0):
263296 # Convert phases and magnitude range into a grid suitable for
264297 # building contours
265298 mags = sp .linspace (10 ** (mag_min / 20.0 ), 10 ** (mag_max / 20.0 ), 2000 )
266- Hphase , Hmag = sp .meshgrid (sp .radians (phases ), mags )
267- return closed_loop_contours (Hmag , Hphase )
299+ Gcl_phases , Gcl_mags = sp .meshgrid (sp .radians (phases ), mags )
300+ return closed_loop_contours (Gcl_mags , Gcl_phases )
0 commit comments