Skip to content

Commit ae8d586

Browse files
authored
Merge pull request #723 from roryyorke/rory/nichols-improvements
Improvements to Nichols chart plotting
2 parents dd95f35 + 59cd872 commit ae8d586

File tree

2 files changed

+159
-27
lines changed

2 files changed

+159
-27
lines changed

control/nichols.py

Lines changed: 91 additions & 26 deletions
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,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

control/tests/nichols_test.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
RMM, 31 Mar 2011
44
"""
55

6+
import matplotlib.pyplot as plt
7+
68
import pytest
79

8-
from control import StateSpace, nichols_plot, nichols
10+
from control import StateSpace, nichols_plot, nichols, nichols_grid, pade, tf
911

1012

1113
@pytest.fixture()
@@ -26,3 +28,68 @@ def test_nichols(tsys, mplcleanup):
2628
def test_nichols_alias(tsys, mplcleanup):
2729
"""Test the control.nichols alias and the grid=False parameter"""
2830
nichols(tsys, grid=False)
31+
32+
33+
@pytest.mark.usefixtures("mplcleanup")
34+
class TestNicholsGrid:
35+
def test_ax(self):
36+
# check grid is plotted into gca, or specified axis
37+
fig, axs = plt.subplots(2,2)
38+
plt.sca(axs[0,1])
39+
40+
cl_mag_lines = nichols_grid()[1]
41+
assert cl_mag_lines[0].axes is axs[0, 1]
42+
43+
cl_mag_lines = nichols_grid(ax=axs[1,1])[1]
44+
assert cl_mag_lines[0].axes is axs[1, 1]
45+
# nichols_grid didn't change what the "current axes" are
46+
assert plt.gca() is axs[0, 1]
47+
48+
49+
def test_cl_phase_label_control(self):
50+
# test label_cl_phases argument
51+
cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \
52+
= nichols_grid()
53+
assert len(cl_phase_labels) > 0
54+
55+
cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \
56+
= nichols_grid(label_cl_phases=False)
57+
assert len(cl_phase_labels) == 0
58+
59+
60+
def test_labels_clipped(self):
61+
# regression test: check that contour labels are clipped
62+
mcontours, ncontours, mlabels, nlabels = nichols_grid()
63+
assert all(ml.get_clip_on() for ml in mlabels)
64+
assert all(nl.get_clip_on() for nl in nlabels)
65+
66+
67+
def test_minimal_phase(self):
68+
# regression test: phase extent is minimal
69+
g = tf([1],[1,1]) * tf([1],[1/1, 2*0.1/1, 1])
70+
nichols(g)
71+
ax = plt.gca()
72+
assert ax.get_xlim()[1] <= 0
73+
74+
75+
def test_fixed_view(self):
76+
# respect xlim, ylim set by user
77+
g = (tf([1],[1/1, 2*0.01/1, 1])
78+
* tf([1],[1/100**2, 2*0.001/100, 1])
79+
* tf(*pade(0.01, 5)))
80+
81+
# normally a broad axis
82+
nichols(g)
83+
84+
assert(plt.xlim()[0] == -1440)
85+
assert(plt.ylim()[0] <= -240)
86+
87+
nichols(g, grid=False)
88+
89+
# zoom in
90+
plt.axis([-360,0,-40,50])
91+
92+
# nichols_grid doesn't expand limits
93+
nichols_grid()
94+
assert(plt.xlim()[0] == -360)
95+
assert(plt.ylim()[1] >= -40)

0 commit comments

Comments
 (0)