Skip to content

Commit 6338d2b

Browse files
Allan McInnesAllan McInnes
authored andcommitted
Add the ability to define custom Nichols charts by passing magnitude and/or phase arrays to nichols_grid. Also some tweaks to the chart grid generation and axis configuration, and further clean-up of documentation and naming.
1 parent 7da3f54 commit 6338d2b

1 file changed

Lines changed: 96 additions & 60 deletions

File tree

src/freqplot.py

Lines changed: 96 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def nyquist(syslist, omega=None):
181181
# Nichols plot
182182
# Contributed by Allan McInnes <Allan.McInnes@canterbury.ac.nz>
183183
#! TODO: need unit test code
184-
def nichols(syslist, omega=None):
184+
def nichols(syslist, omega=None, grid=True):
185185
"""Nichols plot for a system
186186
187187
Usage
@@ -196,6 +196,8 @@ def nichols(syslist, omega=None):
196196
List of linear input/output systems (single system is OK)
197197
omega : freq_range
198198
Range of frequencies (list or bounds) in rad/sec
199+
grid : boolean
200+
True if the plot should include a Nichols-chart grid
199201
200202
Return values
201203
-------------
@@ -228,88 +230,114 @@ def nichols(syslist, omega=None):
228230

229231
# Mark the -180 point
230232
plt.plot([-180], [0], 'r+')
233+
234+
# Add grid
235+
if grid:
236+
nichols_grid()
231237

232238
# Nichols grid
233-
def nichols_grid():
239+
def nichols_grid(**kwargs):
234240
"""Nichols chart grid
235241
236242
Usage
237243
=====
238244
nichols_grid()
239245
240-
Plots a Nichols chart grid on the current axis.
246+
Plots a Nichols chart grid on the current axis, or creates a new chart
247+
if no plot already exists.
241248
242249
Parameters
243250
----------
244-
None
251+
cl_mags : array-like (dB)
252+
Array of closed-loop magnitudes defining the iso-gain lines on a
253+
custom Nichols chart.
254+
cl_phases : array-like (degrees)
255+
Array of closed-loop phases defining the iso-phase lines on a custom
256+
Nichols chart. Must be in the range -360 < cl_phases < 0
245257
246258
Return values
247259
-------------
248260
None
249261
"""
250-
mag_min_default = -40.0 # dB
251-
mag_step = 20.0 # dB
252-
253-
# Chart defaults
254-
phase_min, phase_max, mag_min, mag_max = -360.0, 0.0, mag_min_default, 40.0
255-
256-
# Set actual chart bounds based on current plot
262+
# Default chart size
263+
ol_phase_min = -359.99
264+
ol_phase_max = 0.0
265+
ol_mag_min = -40.0
266+
ol_mag_max = default_ol_mag_max = 50.0
267+
268+
# Find bounds of the current dataset, if there is one.
257269
if plt.gcf().gca().has_data():
258-
phase_min, phase_max, mag_min, mag_max = plt.axis()
259-
270+
ol_phase_min, ol_phase_max, ol_mag_min, ol_mag_max = plt.axis()
271+
260272
# M-circle magnitudes.
261-
# The "fixed" set are always generated, since this guarantees a recognizable
262-
# Nichols chart grid.
263-
mags_fixed = np.array([-40.0, -20.0, -12.0, -6.0, -3.0, -1.0, -0.5, 0.0,
264-
0.25, 0.5, 1.0, 3.0, 6.0, 12.0])
265-
266-
if mag_min < mag_min_default:
267-
# Outside the "fixed" set of magnitudes, the generated M-circles
268-
# are extended in steps of 'mag_step' dB to cover anything made
269-
# visible by the range of the existing plot
270-
mags_adjust = np.arange(mag_step*np.floor(mag_min/mag_step),
271-
mag_min_default, mag_step)
272-
mags = np.concatenate((mags_adjust, mags_fixed))
273+
if kwargs.has_key('cl_mags'):
274+
# Custom chart
275+
cl_mags = kwargs['cl_mags']
273276
else:
274-
mags = mags_fixed
277+
# Default chart magnitudes
278+
# The key set of magnitudes are always generated, since this
279+
# guarantees a recognizable Nichols chart grid.
280+
key_cl_mags = np.array([-40.0, -20.0, -12.0, -6.0, -3.0, -1.0, -0.5, 0.0,
281+
0.25, 0.5, 1.0, 3.0, 6.0, 12.0])
282+
# Extend the range of magnitudes if necessary. The extended arange
283+
# will end up empty if no extension is required. Assumes that closed-loop
284+
# magnitudes are approximately aligned with open-loop magnitudes beyond
285+
# the value of np.min(key_cl_mags)
286+
cl_mag_step = -20.0 # dB
287+
extended_cl_mags = np.arange(np.min(key_cl_mags),
288+
ol_mag_min + cl_mag_step, cl_mag_step)
289+
cl_mags = np.concatenate((extended_cl_mags, key_cl_mags))
275290

276291
# N-circle phases (should be in the range -360 to 0)
277-
phases = np.array([-0.25, -10.0, -20.0, -30.0, -45.0, -60.0, -90.0,
278-
-120.0, -150.0, -180.0, -210.0, -240.0, -270.0,
279-
-310.0, -325.0, -340.0, -350.0, -359.75])
292+
if kwargs.has_key('cl_phases'):
293+
# Custom chart
294+
cl_phases = kwargs['cl_phases']
295+
assert ((-360.0 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0))
296+
else:
297+
# Choose a reasonable set of default phases (denser if the open-loop
298+
# data is restricted to a relatively small range of phases).
299+
key_cl_phases = np.array([-0.25, -45.0, -90.0, -180.0, -270.0, -325.0, -359.75])
300+
if np.abs(ol_phase_max - ol_phase_min) < 90.0:
301+
other_cl_phases = np.arange(-10.0, -360.0, -10.0)
302+
else:
303+
other_cl_phases = np.arange(-10.0, -360.0, -20.0)
304+
cl_phases = np.concatenate((key_cl_phases, other_cl_phases))
280305

281306
# Find the M-contours
282-
m = m_circles(mags, phase_min=np.min(phases), phase_max=np.max(phases))
307+
m = m_circles(cl_mags, phase_min=np.min(cl_phases), phase_max=np.max(cl_phases))
283308
m_mag = 20*sp.log10(np.abs(m))
284309
m_phase = sp.mod(sp.degrees(sp.angle(m)), -360.0) # Unwrap
285310

286311
# Find the N-contours
287-
n = n_circles(phases, mag_min=np.min(mags), mag_max=np.max(mags))
312+
n = n_circles(cl_phases, mag_min=np.min(cl_mags), mag_max=np.max(cl_mags))
288313
n_mag = 20*sp.log10(np.abs(n))
289314
n_phase = sp.mod(sp.degrees(sp.angle(n)), -360.0) # Unwrap
290315

291316
# Plot the contours behind other plot elements.
292317
# The "phase offset" is used to produce copies of the chart that cover
293318
# the entire range of the plotted data, starting from a base chart computed
294-
# over the range -360 < phase < 0 (see above). Given the range
319+
# over the range -360 < phase < 0. Given the range
295320
# the base chart is computed over, the phase offset should be 0
296-
# for -360 < phase_min < 0.
297-
phase_offset_min = 360.0*np.ceil(phase_min/360.0)
298-
phase_offset_max = 360.0*np.ceil(phase_max/360.0) + 360.0
321+
# for -360 < ol_phase_min < 0.
322+
phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0)
323+
phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0
299324
phase_offsets = np.arange(phase_offset_min, phase_offset_max, 360.0)
325+
300326
for phase_offset in phase_offsets:
327+
# Draw M and N contours
301328
plt.plot(m_phase + phase_offset, m_mag, color='gray',
302329
linestyle='dashed', zorder=0)
303330
plt.plot(n_phase + phase_offset, n_mag, color='gray',
304331
linestyle='dashed', zorder=0)
305332

306-
# Add magnitude labels
307-
for x, y, m in zip(m_phase[:][-1], m_mag[:][-1], mags):
308-
align = 'right' if m < 0.0 else 'left'
309-
plt.text(x, y, str(m) + ' dB', size='small', ha=align)
333+
# Add magnitude labels
334+
for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1], cl_mags):
335+
align = 'right' if m < 0.0 else 'left'
336+
plt.text(x, y, str(m) + ' dB', size='small', ha=align)
310337

311-
# Make sure axes conform to any pre-existing plot.
312-
plt.axis([phase_min, phase_max, mag_min, mag_max])
338+
# Fit axes to generated chart
339+
plt.axis([phase_offset_min - 360.0, phase_offset_max - 360.0,
340+
np.min(cl_mags), np.max([ol_mag_max, default_ol_mag_max])])
313341

314342
# Gang of Four
315343
#! TODO: think about how (and whether) to handle lists of systems
@@ -417,38 +445,44 @@ def default_frequency_range(syslist):
417445
return omega
418446

419447
# Compute contours of a closed-loop transfer function
420-
def closed_loop_contours(Hmag, Hphase):
421-
"""Contours of the function H = G/(1+G).
448+
def closed_loop_contours(Gcl_mags, Gcl_phases):
449+
"""Contours of the function Gcl = Gol/(1+Gol), where
450+
Gol is an open-loop transfer function, and Gcl is a corresponding
451+
closed-loop transfer function.
422452
423453
Usage
424454
=====
425-
contours = closed_loop_contours(mags, phases)
455+
contours = closed_loop_contours(Gcl_mags, Gcl_phases)
426456
427457
Parameters
428458
----------
429-
mags : array-like
430-
Meshgrid array of magnitudes of the contours
431-
phases : array-like
432-
Meshgrid array of phases in radians of the contours
459+
Gcl_mags : array-like
460+
Array of magnitudes of the contours
461+
Gcl_phases : array-like
462+
Array of phases in radians of the contours
433463
434464
Return values
435465
-------------
436466
contours : complex array
437467
Array of complex numbers corresponding to the contours.
438468
"""
439-
# Compute the contours in H-space
440-
H = Hmag*sp.exp(1.j*Hphase)
469+
# Compute the contours in Gcl-space. Since we're given closed-loop
470+
# magnitudes and phases, this is just a case of converting them into
471+
# a complex number.
472+
Gcl = Gcl_mags*sp.exp(1.j*Gcl_phases)
441473

442-
# Invert H = G/(1+G) to get an expression for the contours in G-space
443-
return H/(1.0 - H)
474+
# Invert Gcl = Gol/(1+Gol) to map the contours into the open-loop space
475+
return Gcl/(1.0 - Gcl)
444476

445477
# M-circle
446478
def m_circles(mags, phase_min=-359.75, phase_max=-0.25):
447-
"""Constant-magnitude contours of the function H = G/(1+G).
479+
"""Constant-magnitude contours of the function Gcl = Gol/(1+Gol), where
480+
Gol is an open-loop transfer function, and Gcl is a corresponding
481+
closed-loop transfer function.
448482
449483
Usage
450484
=====
451-
contours = m_circles(mags)
485+
contours = m_circles(mags, phase_min, phase_max)
452486
453487
Parameters
454488
----------
@@ -467,16 +501,18 @@ def m_circles(mags, phase_min=-359.75, phase_max=-0.25):
467501
# Convert magnitudes and phase range into a grid suitable for
468502
# building contours
469503
phases = sp.radians(sp.linspace(phase_min, phase_max, 500))
470-
Hmag, Hphase = sp.meshgrid(10.0**(mags/20.0), phases)
471-
return closed_loop_contours(Hmag, Hphase)
504+
Gcl_mags, Gcl_phases = sp.meshgrid(10.0**(mags/20.0), phases)
505+
return closed_loop_contours(Gcl_mags, Gcl_phases)
472506

473507
# N-circle
474508
def n_circles(phases, mag_min=-40.0, mag_max=12.0):
475-
"""Constant-phase contours of the function H = G/(1+G).
509+
"""Constant-phase contours of the function Gcl = Gol/(1+Gol), where
510+
Gol is an open-loop transfer function, and Gcl is a corresponding
511+
closed-loop transfer function.
476512
477513
Usage
478514
=====
479-
contour = n_circles(angles)
515+
contours = n_circles(phases, mag_min, mag_max)
480516
481517
Parameters
482518
----------
@@ -495,5 +531,5 @@ def n_circles(phases, mag_min=-40.0, mag_max=12.0):
495531
# Convert phases and magnitude range into a grid suitable for
496532
# building contours
497533
mags = sp.linspace(10**(mag_min/20.0), 10**(mag_max/20.0), 2000)
498-
Hphase, Hmag = sp.meshgrid(sp.radians(phases), mags)
499-
return closed_loop_contours(Hmag, Hphase)
534+
Gcl_phases, Gcl_mags = sp.meshgrid(sp.radians(phases), mags)
535+
return closed_loop_contours(Gcl_mags, Gcl_phases)

0 commit comments

Comments
 (0)