51
51
52
52
import numpy as np
53
53
import matplotlib .pyplot as plt
54
+ import matplotlib .transforms
55
+
54
56
from .ctrlutil import unwrap
55
57
from .freqplot import _default_frequency_range
56
58
from .import config
@@ -119,7 +121,18 @@ def nichols_plot(sys_list, omega=None, grid=None):
119
121
nichols_grid ()
120
122
121
123
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 ):
123
136
"""Nichols chart grid
124
137
125
138
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'):
136
149
line_style : string, optional
137
150
:doc:`Matplotlib linestyle\
138
151
<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.
139
156
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``
140
169
"""
170
+ if ax is None :
171
+ ax = plt .gca ()
172
+
141
173
# Default chart size
142
174
ol_phase_min = - 359.99
143
175
ol_phase_max = 0.0
144
176
ol_mag_min = - 40.0
145
177
ol_mag_max = default_ol_mag_max = 50.0
146
178
147
- # Find bounds of the current dataset, ifthere 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 )
150
182
151
183
# M-circle magnitudes.
152
184
if cl_mags is None :
@@ -165,19 +197,22 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
165
197
ol_mag_min + cl_mag_step ,cl_mag_step )
166
198
cl_mags = np .concatenate ((extended_cl_mags ,key_cl_mags ))
167
199
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
+
168
205
# N-circle phases (should be in the range -360 to 0)
169
206
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' )
181
216
182
217
# Find the M-contours
183
218
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'):
196
231
# over the range -360 < phase < 0. Given the range
197
232
# the base chart is computed over, the phase offset should be 0
198
233
# 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 = []
202
240
203
241
for phase_offset in phase_offsets :
204
242
# 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 ))
209
249
210
250
# Add magnitude labels
211
251
for x ,y ,m in zip (m_phase [:][- 1 ]+ phase_offset ,m_mag [:][- 1 ],
212
252
cl_mags ):
213
253
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
+
216
277
217
278
# 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
220
285
221
286
#
222
287
# Utility functions