Merge pull request #723 from roryyorke/rory/nichols-improvements · python-control/python-control@ae8d586

@@ -51,6 +51,8 @@

51515252

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()

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

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

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.

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))

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)

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')

181216182217

# 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 = []

202240203241

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))

209249210250

# 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+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