Skip to content

Commit 782e526

Browse files
committed
Improvements to Nichols chart plotting
Clip closed-loop contour labels. Add labels for constant closed-loop phase contours. Use smaller of data or view extents when deciding on how big, in phase, a chart to create. Use more uniformly spaced closed-loop phase contours, and use more widely-spaced contours when phase extent is large. Add optional `ax` argument, for axes to add grid to.
1 parent 983726c commit 782e526

File tree

1 file changed

+60
-21
lines changed

1 file changed

+60
-21
lines changed

control/nichols.py

+60-21
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151

5252
import numpy as np
5353
import matplotlib.pyplot as plt
54+
import matplotlib.transforms
55+
5456
from .ctrlutil import unwrap
5557
from .freqplot import _default_frequency_range
5658
from . 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,23 @@ 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>`
139-
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.
140156
"""
157+
if ax is None:
158+
ax = plt.gca()
159+
141160
# Default chart size
142161
ol_phase_min = -359.99
143162
ol_phase_max = 0.0
144163
ol_mag_min = -40.0
145164
ol_mag_max = default_ol_mag_max = 50.0
146165

147166
# 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()
167+
if ax.has_data():
168+
ol_phase_min, ol_mag_min, ol_phase_max, ol_mag_max = _inner_extents(ax)
150169

151170
# M-circle magnitudes.
152171
if cl_mags is None:
@@ -165,17 +184,18 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
165184
ol_mag_min + cl_mag_step, cl_mag_step)
166185
cl_mags = np.concatenate((extended_cl_mags, key_cl_mags))
167186

187+
phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0)
188+
phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0
189+
168190
# N-circle phases (should be in the range -360 to 0)
169191
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))
192+
# aim for 9 lines, but always show (-360+eps, -180, -eps)
193+
# smallest spacing is 45, biggest is 180
194+
phase_span = phase_offset_max - phase_offset_min
195+
spacing = np.clip(round(phase_span / 8 / 45) * 45, 45, 180)
196+
key_cl_phases = np.array([-0.25, -359.75])
197+
other_cl_phases = np.arange(-spacing, -360.0, -spacing)
198+
cl_phases = np.unique(np.concatenate((key_cl_phases, other_cl_phases)))
179199
else:
180200
assert ((-360.0 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0))
181201

@@ -196,27 +216,46 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'):
196216
# over the range -360 < phase < 0. Given the range
197217
# the base chart is computed over, the phase offset should be 0
198218
# 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
201219
phase_offsets = np.arange(phase_offset_min, phase_offset_max, 360.0)
202220

203221
for phase_offset in phase_offsets:
204222
# Draw M and N contours
205-
plt.plot(m_phase + phase_offset, m_mag, color='lightgray',
223+
ax.plot(m_phase + phase_offset, m_mag, color='lightgray',
206224
linestyle=line_style, zorder=0)
207-
plt.plot(n_phase + phase_offset, n_mag, color='lightgray',
225+
ax.plot(n_phase + phase_offset, n_mag, color='lightgray',
208226
linestyle=line_style, zorder=0)
209227

210228
# Add magnitude labels
211229
for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1],
212230
cl_mags):
213231
align = 'right' if m < 0.0 else 'left'
214-
plt.text(x, y, str(m) + ' dB', size='small', ha=align,
215-
color='gray')
232+
ax.text(x, y, str(m) + ' dB', size='small', ha=align,
233+
color='gray', clip_on=True)
234+
235+
# phase labels
236+
if label_cl_phases:
237+
for x, y, p in zip(n_phase[:][0] + phase_offset,
238+
n_mag[:][0],
239+
cl_phases):
240+
if p > -175:
241+
align = 'right'
242+
elif p > -185:
243+
align = 'center'
244+
else:
245+
align = 'left'
246+
ax.text(x, y, f'{round(p)}\N{DEGREE SIGN}',
247+
size='small',
248+
ha=align,
249+
va='bottom',
250+
color='gray',
251+
clip_on=True)
252+
216253

217254
# 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])])
255+
ax.axis([phase_offset_min - 360.0,
256+
phase_offset_max - 360.0,
257+
np.min(np.concatenate([cl_mags,[ol_mag_min]])),
258+
np.max([ol_mag_max, default_ol_mag_max])])
220259

221260
#
222261
# Utility functions

0 commit comments

Comments
 (0)