5151
5252import numpy as np
5353import matplotlib .pyplot as plt
54+ import matplotlib .transforms
55+
5456from .ctrlutil import unwrap
5557from .freqplot import _default_frequency_range
5658from . import config
@@ -119,7 +121,18 @@ def nichols_plot(sys_list, omega=None, grid=None):
119121 nichols_grid ()
120122
121123
122- def nichols_grid (cl_mags = None , cl_phases = None , line_style = 'dotted' ):
124+ def _inner_extents (ax ):
125+ # intersection of data and view extents
126+ # if intersection empty, return view extents
127+ _inner = matplotlib .transforms .Bbox .intersection (ax .viewLim , ax .dataLim )
128+ if _inner is None :
129+ return ax .ViewLim .extents
130+ else :
131+ return _inner .extents
132+
133+
134+ def nichols_grid (cl_mags = None , cl_phases = None , line_style = 'dotted' , ax = None ,
135+ label_cl_phases = True ):
123136 """Nichols chart grid
124137
125138 Plots a Nichols chart grid on the current axis, or creates a new chart
@@ -136,17 +149,36 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
136149 line_style : string, optional
137150 :doc:`Matplotlib linestyle \
138151 <matplotlib:gallery/lines_bars_and_markers/linestyles>`
152+ ax : matplotlib.axes.Axes, optional
153+ Axes to add grid to. If ``None``, use ``plt.gca()``.
154+ label_cl_phases: bool, optional
155+ If True, closed-loop phase lines will be labelled.
139156
157+ Returns
158+ -------
159+ cl_mag_lines: list of `matplotlib.line.Line2D`
160+ The constant closed-loop gain contours
161+ cl_phase_lines: list of `matplotlib.line.Line2D`
162+ The constant closed-loop phase contours
163+ cl_mag_labels: list of `matplotlib.text.Text`
164+ mcontour labels; each entry corresponds to the respective entry
165+ in ``cl_mag_lines``
166+ cl_phase_labels: list of `matplotlib.text.Text`
167+ ncontour labels; each entry corresponds to the respective entry
168+ in ``cl_phase_lines``
140169 """
170+ if ax is None :
171+ ax = plt .gca ()
172+
141173 # Default chart size
142174 ol_phase_min = - 359.99
143175 ol_phase_max = 0.0
144176 ol_mag_min = - 40.0
145177 ol_mag_max = default_ol_mag_max = 50.0
146178
147- # Find bounds of the current dataset, if there is one.
148- if plt . gcf (). gca (). has_data ():
149- ol_phase_min , ol_phase_max , ol_mag_min , ol_mag_max = plt . axis ( )
179+ if ax . has_data ():
180+ # Find extent of intersection the current dataset or view
181+ ol_phase_min , ol_mag_min , ol_phase_max , ol_mag_max = _inner_extents ( ax )
150182
151183 # M-circle magnitudes.
152184 if cl_mags is None :
@@ -165,19 +197,22 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
165197 ol_mag_min + cl_mag_step , cl_mag_step )
166198 cl_mags = np .concatenate ((extended_cl_mags , key_cl_mags ))
167199
200+ # a minimum 360deg extent containing the phases
201+ phase_round_max = 360.0 * np .ceil (ol_phase_max / 360.0 )
202+ phase_round_min = min (phase_round_max - 360 ,
203+ 360.0 * np .floor (ol_phase_min / 360.0 ))
204+
168205 # N-circle phases (should be in the range -360 to 0)
169206 if cl_phases is None :
170- # Choose a reasonable set of default phases (denser if the open-loop
171- # data is restricted to a relatively small range of phases).
172- key_cl_phases = np .array ([- 0.25 , - 45.0 , - 90.0 , - 180.0 , - 270.0 ,
173- - 325.0 , - 359.75 ])
174- if np .abs (ol_phase_max - ol_phase_min ) < 90.0 :
175- other_cl_phases = np .arange (- 10.0 , - 360.0 , - 10.0 )
176- else :
177- other_cl_phases = np .arange (- 10.0 , - 360.0 , - 20.0 )
178- cl_phases = np .concatenate ((key_cl_phases , other_cl_phases ))
179- else :
180- assert ((- 360.0 < np .min (cl_phases )) and (np .max (cl_phases ) < 0.0 ))
207+ # aim for 9 lines, but always show (-360+eps, -180, -eps)
208+ # smallest spacing is 45, biggest is 180
209+ phase_span = phase_round_max - phase_round_min
210+ spacing = np .clip (round (phase_span / 8 / 45 ) * 45 , 45 , 180 )
211+ key_cl_phases = np .array ([- 0.25 , - 359.75 ])
212+ other_cl_phases = np .arange (- spacing , - 360.0 , - spacing )
213+ cl_phases = np .unique (np .concatenate ((key_cl_phases , other_cl_phases )))
214+ elif not ((- 360 < np .min (cl_phases )) and (np .max (cl_phases ) < 0.0 )):
215+ raise ValueError ('cl_phases must between -360 and 0, exclusive' )
181216
182217 # Find the M-contours
183218 m = m_circles (cl_mags , phase_min = np .min (cl_phases ),
@@ -196,27 +231,57 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
196231 # over the range -360 < phase < 0. Given the range
197232 # the base chart is computed over, the phase offset should be 0
198233 # for -360 < ol_phase_min < 0.
199- phase_offset_min = 360.0 * np .ceil (ol_phase_min / 360.0 )
200- phase_offset_max = 360.0 * np .ceil (ol_phase_max / 360.0 ) + 360.0
201- phase_offsets = np .arange (phase_offset_min , phase_offset_max , 360.0 )
234+ phase_offsets = 360 + np .arange (phase_round_min , phase_round_max , 360.0 )
235+
236+ cl_mag_lines = []
237+ cl_phase_lines = []
238+ cl_mag_labels = []
239+ cl_phase_labels = []
202240
203241 for phase_offset in phase_offsets :
204242 # Draw M and N contours
205- plt .plot (m_phase + phase_offset , m_mag , color = 'lightgray' ,
206- linestyle = line_style , zorder = 0 )
207- plt .plot (n_phase + phase_offset , n_mag , color = 'lightgray' ,
208- linestyle = line_style , zorder = 0 )
243+ cl_mag_lines .extend (
244+ ax .plot (m_phase + phase_offset , m_mag , color = 'lightgray' ,
245+ linestyle = line_style , zorder = 0 ))
246+ cl_phase_lines .extend (
247+ ax .plot (n_phase + phase_offset , n_mag , color = 'lightgray' ,
248+ linestyle = line_style , zorder = 0 ))
209249
210250 # Add magnitude labels
211251 for x , y , m in zip (m_phase [:][- 1 ] + phase_offset , m_mag [:][- 1 ],
212252 cl_mags ):
213253 align = 'right' if m < 0.0 else 'left'
214- plt .text (x , y , str (m ) + ' dB' , size = 'small' , ha = align ,
215- color = 'gray' )
254+ cl_mag_labels .append (
255+ ax .text (x , y , str (m ) + ' dB' , size = 'small' , ha = align ,
256+ color = 'gray' , clip_on = True ))
257+
258+ # phase labels
259+ if label_cl_phases :
260+ for x , y , p in zip (n_phase [:][0 ] + phase_offset ,
261+ n_mag [:][0 ],
262+ cl_phases ):
263+ if p > - 175 :
264+ align = 'right'
265+ elif p > - 185 :
266+ align = 'center'
267+ else :
268+ align = 'left'
269+ cl_phase_labels .append (
270+ ax .text (x , y , f'{ round (p )} \N{DEGREE SIGN} ' ,
271+ size = 'small' ,
272+ ha = align ,
273+ va = 'bottom' ,
274+ color = 'gray' ,
275+ clip_on = True ))
276+
216277
217278 # Fit axes to generated chart
218- plt .axis ([phase_offset_min - 360.0 , phase_offset_max - 360.0 ,
219- np .min (cl_mags ), np .max ([ol_mag_max , default_ol_mag_max ])])
279+ ax .axis ([phase_round_min ,
280+ phase_round_max ,
281+ np .min (np .concatenate ([cl_mags ,[ol_mag_min ]])),
282+ np .max ([ol_mag_max , default_ol_mag_max ])])
283+
284+ return cl_mag_lines , cl_phase_lines , cl_mag_labels , cl_phase_labels
220285
221286#
222287# Utility functions
0 commit comments