Merge pull request #723 from roryyorke/rory/nichols-improvements · python-control/python-control@ae8d586
@@ -51,6 +51,8 @@
51515252import 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):
119121nichols_grid()
120122121123122-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
124137125138 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.
139156157+ 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
142174ol_phase_min = -359.99
143175ol_phase_max = 0.0
144176ol_mag_min = -40.0
145177ol_mag_max = default_ol_mag_max = 50.0
146178147-# 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)
150182151183# M-circle magnitudes.
152184if cl_mags is None:
@@ -165,19 +197,22 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
165197ol_mag_min + cl_mag_step, cl_mag_step)
166198cl_mags = np.concatenate((extended_cl_mags, key_cl_mags))
167199200+# 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)
169206if 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')
181216182217# Find the M-contours
183218m = 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 = []
202240203241for 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))
209249210250# Add magnitude labels
211251for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1],
212252cl_mags):
213253align = '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+216277217278# 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
220285221286#
222287# Utility functions