Skip to content

Commit 0f31ff6

Browse files
committed
updated Nichols chart code to match 0.3d
1 parent 1ecf93a commit 0f31ff6

5 files changed

Lines changed: 116 additions & 69 deletions

File tree

ChangeLog

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
2011-04-02 Richard Murray <murray@malabar.local>
2+
3+
* tests/nichols_test.py (TestStateSpace.testNgrid): updated testcode
4+
to turn off grid in initial Nichols chart plot.
5+
6+
* src/freqplot.py: updated comments at top of file to reflect
7+
nichols chart move
8+
9+
* src/nichols.py: transferred over changes from v0.3d
10+
11+
* src/matlab.py (ngrid): moved import to function
12+
113
2011-03-31 Richard Murray <murray@malabar.local>
214

315
* examples/pvtol-nested.py: updated stability margin plot to use

src/freqplot.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# Date: 24 May 09
55
#
66
# This file contains some standard control system plots: Bode plots,
7-
# Nyquist plots, Nichols plots and pole-zero diagrams
7+
# Nyquist plots and pole-zero diagrams. The code for Nichols charts
8+
# is in nichols.py.
89
#
910
# Copyright (c) 2010 by California Institute of Technology
1011
# All rights reserved.

src/matlab.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
# Import MATLAB-like functions that can be used as-is
7676
from ctrlutil import unwrap
7777
from freqplot import nyquist, gangof4
78-
from nichols import nichols, nichols_grid
78+
from nichols import nichols
7979
from bdalg import series, parallel, negate, feedback
8080
from pzmap import pzmap
8181
from statefbk import ctrb, obsv, gram, place, lqr
@@ -753,6 +753,7 @@ def ngrid():
753753
=====
754754
ngrid()
755755
"""
756+
from nichols import nichols_grid
756757
nichols_grid()
757758

758759
#

src/nichols.py

Lines changed: 98 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
from ctrlutil import unwrap
4646
from freqplot import default_frequency_range
4747

48-
def nichols(syslist, omega=None):
48+
# Nichols plot
49+
def nichols(syslist, omega=None, grid=True):
4950
"""Nichols plot for a system
5051
5152
Usage
@@ -60,6 +61,8 @@ def nichols(syslist, omega=None):
6061
List of linear input/output systems (single system is OK)
6162
omega : freq_range
6263
Range of frequencies (list or bounds) in rad/sec
64+
grid : boolean, optional
65+
True if the plot should include a Nichols-chart grid. Default is True.
6366
6467
Return values
6568
-------------
@@ -71,7 +74,7 @@ def nichols(syslist, omega=None):
7174
syslist = (syslist,)
7275

7376
# Select a default range if none is provided
74-
if (omega == None):
77+
if omega is None:
7578
omega = default_frequency_range(syslist)
7679

7780
for sys in syslist:
@@ -94,88 +97,110 @@ def nichols(syslist, omega=None):
9497

9598
# Mark the -180 point
9699
plt.plot([-180], [0], 'r+')
100+
101+
# Add grid
102+
if grid:
103+
nichols_grid()
97104

98105
# Nichols grid
99-
def nichols_grid():
106+
#! TODO: Consider making linestyle configurable
107+
def nichols_grid(cl_mags=None, cl_phases=None):
100108
"""Nichols chart grid
101109
102110
Usage
103111
=====
104112
nichols_grid()
105113
106-
Plots a Nichols chart grid on the current axis.
114+
Plots a Nichols chart grid on the current axis, or creates a new chart
115+
if no plot already exists.
107116
108117
Parameters
109118
----------
110-
None
119+
cl_mags : array-like (dB), optional
120+
Array of closed-loop magnitudes defining the iso-gain lines on a
121+
custom Nichols chart.
122+
cl_phases : array-like (degrees), optional
123+
Array of closed-loop phases defining the iso-phase lines on a custom
124+
Nichols chart. Must be in the range -360 < cl_phases < 0
111125
112126
Return values
113127
-------------
114128
None
115129
"""
116-
mag_min_default = -40.0 # dB
117-
mag_step = 20.0 # dB
118-
119-
# Chart defaults
120-
phase_min, phase_max, mag_min, mag_max = -360.0, 0.0, mag_min_default, 40.0
121-
122-
# Set actual chart bounds based on current plot
130+
# Default chart size
131+
ol_phase_min = -359.99
132+
ol_phase_max = 0.0
133+
ol_mag_min = -40.0
134+
ol_mag_max = default_ol_mag_max = 50.0
135+
136+
# Find bounds of the current dataset, if there is one.
123137
if plt.gcf().gca().has_data():
124-
phase_min, phase_max, mag_min, mag_max = plt.axis()
125-
138+
ol_phase_min, ol_phase_max, ol_mag_min, ol_mag_max = plt.axis()
139+
126140
# M-circle magnitudes.
127-
# The "fixed" set are always generated, since this guarantees a recognizable
128-
# Nichols chart grid.
129-
mags_fixed = np.array([-40.0, -20.0, -12.0, -6.0, -3.0, -1.0, -0.5, 0.0,
130-
0.25, 0.5, 1.0, 3.0, 6.0, 12.0])
131-
132-
if mag_min < mag_min_default:
133-
# Outside the "fixed" set of magnitudes, the generated M-circles
134-
# are extended in steps of 'mag_step' dB to cover anything made
135-
# visible by the range of the existing plot
136-
mags_adjust = np.arange(mag_step*np.floor(mag_min/mag_step),
137-
mag_min_default, mag_step)
138-
mags = np.concatenate((mags_adjust, mags_fixed))
139-
else:
140-
mags = mags_fixed
141+
if cl_mags is None:
142+
# Default chart magnitudes
143+
# The key set of magnitudes are always generated, since this
144+
# guarantees a recognizable Nichols chart grid.
145+
key_cl_mags = np.array([-40.0, -20.0, -12.0, -6.0, -3.0, -1.0, -0.5, 0.0,
146+
0.25, 0.5, 1.0, 3.0, 6.0, 12.0])
147+
# Extend the range of magnitudes if necessary. The extended arange
148+
# will end up empty if no extension is required. Assumes that closed-loop
149+
# magnitudes are approximately aligned with open-loop magnitudes beyond
150+
# the value of np.min(key_cl_mags)
151+
cl_mag_step = -20.0 # dB
152+
extended_cl_mags = np.arange(np.min(key_cl_mags),
153+
ol_mag_min + cl_mag_step, cl_mag_step)
154+
cl_mags = np.concatenate((extended_cl_mags, key_cl_mags))
141155

142156
# N-circle phases (should be in the range -360 to 0)
143-
phases = np.array([-0.25, -10.0, -20.0, -30.0, -45.0, -60.0, -90.0,
144-
-120.0, -150.0, -180.0, -210.0, -240.0, -270.0,
145-
-310.0, -325.0, -340.0, -350.0, -359.75])
157+
if cl_phases is None:
158+
# Choose a reasonable set of default phases (denser if the open-loop
159+
# data is restricted to a relatively small range of phases).
160+
key_cl_phases = np.array([-0.25, -45.0, -90.0, -180.0, -270.0, -325.0, -359.75])
161+
if np.abs(ol_phase_max - ol_phase_min) < 90.0:
162+
other_cl_phases = np.arange(-10.0, -360.0, -10.0)
163+
else:
164+
other_cl_phases = np.arange(-10.0, -360.0, -20.0)
165+
cl_phases = np.concatenate((key_cl_phases, other_cl_phases))
166+
else:
167+
assert ((-360.0 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0))
146168

147169
# Find the M-contours
148-
m = m_circles(mags, phase_min=np.min(phases), phase_max=np.max(phases))
170+
m = m_circles(cl_mags, phase_min=np.min(cl_phases), phase_max=np.max(cl_phases))
149171
m_mag = 20*sp.log10(np.abs(m))
150172
m_phase = sp.mod(sp.degrees(sp.angle(m)), -360.0) # Unwrap
151173

152174
# Find the N-contours
153-
n = n_circles(phases, mag_min=np.min(mags), mag_max=np.max(mags))
175+
n = n_circles(cl_phases, mag_min=np.min(cl_mags), mag_max=np.max(cl_mags))
154176
n_mag = 20*sp.log10(np.abs(n))
155177
n_phase = sp.mod(sp.degrees(sp.angle(n)), -360.0) # Unwrap
156178

157179
# Plot the contours behind other plot elements.
158180
# The "phase offset" is used to produce copies of the chart that cover
159181
# the entire range of the plotted data, starting from a base chart computed
160-
# over the range -360 < phase < 0 (see above). Given the range
182+
# over the range -360 < phase < 0. Given the range
161183
# the base chart is computed over, the phase offset should be 0
162-
# for -360 < phase_min < 0.
163-
phase_offset_min = 360.0*np.ceil(phase_min/360.0)
164-
phase_offset_max = 360.0*np.ceil(phase_max/360.0) + 360.0
184+
# for -360 < ol_phase_min < 0.
185+
phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0)
186+
phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0
165187
phase_offsets = np.arange(phase_offset_min, phase_offset_max, 360.0)
188+
166189
for phase_offset in phase_offsets:
190+
# Draw M and N contours
167191
plt.plot(m_phase + phase_offset, m_mag, color='gray',
168-
linestyle='dashed', zorder=0)
192+
linestyle='dotted', zorder=0)
169193
plt.plot(n_phase + phase_offset, n_mag, color='gray',
170-
linestyle='dashed', zorder=0)
194+
linestyle='dotted', zorder=0)
171195

172-
# Add magnitude labels
173-
for x, y, m in zip(m_phase[:][-1], m_mag[:][-1], mags):
174-
align = 'right' if m < 0.0 else 'left'
175-
plt.text(x, y, str(m) + ' dB', size='small', ha=align)
196+
# Add magnitude labels
197+
for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1], cl_mags):
198+
align = 'right' if m < 0.0 else 'left'
199+
plt.text(x, y, str(m) + ' dB', size='small', ha=align, color='gray')
176200

177-
# Make sure axes conform to any pre-existing plot.
178-
plt.axis([phase_min, phase_max, mag_min, mag_max])
201+
# Fit axes to generated chart
202+
plt.axis([phase_offset_min - 360.0, phase_offset_max - 360.0,
203+
np.min(cl_mags), np.max([ol_mag_max, default_ol_mag_max])])
179204

180205
#
181206
# Utility functions
@@ -185,38 +210,44 @@ def nichols_grid():
185210
#
186211

187212
# Compute contours of a closed-loop transfer function
188-
def closed_loop_contours(Hmag, Hphase):
189-
"""Contours of the function H = G/(1+G).
213+
def closed_loop_contours(Gcl_mags, Gcl_phases):
214+
"""Contours of the function Gcl = Gol/(1+Gol), where
215+
Gol is an open-loop transfer function, and Gcl is a corresponding
216+
closed-loop transfer function.
190217
191218
Usage
192219
=====
193-
contours = closed_loop_contours(mags, phases)
220+
contours = closed_loop_contours(Gcl_mags, Gcl_phases)
194221
195222
Parameters
196223
----------
197-
mags : array-like
198-
Meshgrid array of magnitudes of the contours
199-
phases : array-like
200-
Meshgrid array of phases in radians of the contours
224+
Gcl_mags : array-like
225+
Array of magnitudes of the contours
226+
Gcl_phases : array-like
227+
Array of phases in radians of the contours
201228
202229
Return values
203230
-------------
204231
contours : complex array
205232
Array of complex numbers corresponding to the contours.
206233
"""
207-
# Compute the contours in H-space
208-
H = Hmag*sp.exp(1.j*Hphase)
234+
# Compute the contours in Gcl-space. Since we're given closed-loop
235+
# magnitudes and phases, this is just a case of converting them into
236+
# a complex number.
237+
Gcl = Gcl_mags*sp.exp(1.j*Gcl_phases)
209238

210-
# Invert H = G/(1+G) to get an expression for the contours in G-space
211-
return H/(1.0 - H)
239+
# Invert Gcl = Gol/(1+Gol) to map the contours into the open-loop space
240+
return Gcl/(1.0 - Gcl)
212241

213242
# M-circle
214243
def m_circles(mags, phase_min=-359.75, phase_max=-0.25):
215-
"""Constant-magnitude contours of the function H = G/(1+G).
244+
"""Constant-magnitude contours of the function Gcl = Gol/(1+Gol), where
245+
Gol is an open-loop transfer function, and Gcl is a corresponding
246+
closed-loop transfer function.
216247
217248
Usage
218249
=====
219-
contours = m_circles(mags)
250+
contours = m_circles(mags, phase_min, phase_max)
220251
221252
Parameters
222253
----------
@@ -234,17 +265,19 @@ def m_circles(mags, phase_min=-359.75, phase_max=-0.25):
234265
"""
235266
# Convert magnitudes and phase range into a grid suitable for
236267
# building contours
237-
phases = sp.radians(sp.linspace(phase_min, phase_max, 500))
238-
Hmag, Hphase = sp.meshgrid(10.0**(mags/20.0), phases)
239-
return closed_loop_contours(Hmag, Hphase)
268+
phases = sp.radians(sp.linspace(phase_min, phase_max, 2000))
269+
Gcl_mags, Gcl_phases = sp.meshgrid(10.0**(mags/20.0), phases)
270+
return closed_loop_contours(Gcl_mags, Gcl_phases)
240271

241272
# N-circle
242273
def n_circles(phases, mag_min=-40.0, mag_max=12.0):
243-
"""Constant-phase contours of the function H = G/(1+G).
274+
"""Constant-phase contours of the function Gcl = Gol/(1+Gol), where
275+
Gol is an open-loop transfer function, and Gcl is a corresponding
276+
closed-loop transfer function.
244277
245278
Usage
246279
=====
247-
contour = n_circles(angles)
280+
contours = n_circles(phases, mag_min, mag_max)
248281
249282
Parameters
250283
----------
@@ -263,5 +296,5 @@ def n_circles(phases, mag_min=-40.0, mag_max=12.0):
263296
# Convert phases and magnitude range into a grid suitable for
264297
# building contours
265298
mags = sp.linspace(10**(mag_min/20.0), 10**(mag_max/20.0), 2000)
266-
Hphase, Hmag = sp.meshgrid(sp.radians(phases), mags)
267-
return closed_loop_contours(Hmag, Hphase)
299+
Gcl_phases, Gcl_mags = sp.meshgrid(sp.radians(phases), mags)
300+
return closed_loop_contours(Gcl_mags, Gcl_phases)

tests/nichols_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ def setUp(self):
2020

2121
self.sys = StateSpace(A, B, C, D)
2222

23-
def testNichols(self):
23+
def testNicholsPlain(self):
2424
"""Generate a Nichols plot."""
2525
nichols(self.sys)
2626

2727
def testNgrid(self):
2828
"""Generate a Nichols plot."""
29-
nichols(self.sys)
29+
nichols(self.sys, grid=False)
3030
ngrid()
3131

3232
def suite():

0 commit comments

Comments
 (0)