Skip to content

Commit dba4831

Browse files
author
Sawyer Fuller
committed
added margin command and associated matlab-like wrapper (does not plot yet). bugfixes to bode and default_frequency_range.
1 parent 472983b commit dba4831

File tree

2 files changed

+162
-11
lines changed

2 files changed

+162
-11
lines changed

src/freqplot.py

+129-11
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@
5555
#
5656

5757
# Bode plot
58-
def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
58+
def bode(syslist, omega=None, dB=False, Hz=False, deg=True,
59+
color=None, Plot=True):
5960
"""Bode plot for a system
6061
6162
Usage
6263
=====
63-
(mag, phase, omega) = bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True)
64+
(mag, phase, omega) = bode(syslist, omega=None, dB=False, Hz=False, color=None, deg=True, Plot=True)
6465
6566
Plots a Bode plot for the system over a (optional) frequency range.
6667
@@ -74,23 +75,30 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
7475
If True, plot result in dB
7576
Hz : boolean
7677
If True, plot frequency in Hz (omega must be provided in rad/sec)
78+
color : matplotlib color
79+
Color of line in bode plot
80+
deg : boolean
81+
If True, return phase in degrees (else radians)
7782
Plot : boolean
7883
If True, plot magnitude and phase
7984
8085
Return values
8186
-------------
82-
mag : magnitude array
83-
phase : phase array
84-
omega : frequency array
87+
mag : magnitude array (list if len(syslist) > 1)
88+
phase : phase array (list if len(syslist) > 1)
89+
omega : frequency array (list if len(syslist) > 1)
8590
8691
Notes
8792
-----
88-
1. Alternatively, may use (mag, phase, freq) = sys.freqresp(freq) to generate the frequency response for a system.
93+
1. Alternatively, you may use the lower-level method
94+
(mag, phase, freq) = sys.freqresp(freq) to generate the frequency
95+
response for a system, but it returns a MIMO response.
8996
"""
9097
# If argument was a singleton, turn it into a list
9198
if (not getattr(syslist, '__iter__', False)):
9299
syslist = (syslist,)
93100

101+
mags, phases, omegas = [], [], []
94102
for sys in syslist:
95103
if (sys.inputs > 1 or sys.outputs > 1):
96104
#TODO: Add MIMO bode plots.
@@ -104,10 +112,14 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
104112
mag_tmp, phase_tmp, omega = sys.freqresp(omega)
105113
mag = np.squeeze(mag_tmp)
106114
phase = np.squeeze(phase_tmp)
115+
phase = unwrap(phase)
107116
if Hz: omega = omega/(2*sp.pi)
108117
if dB: mag = 20*sp.log10(mag)
109-
phase = unwrap(phase*180/sp.pi, 360)
110-
118+
if deg: phase = phase * 180 / sp.pi
119+
120+
mags.append(mag)
121+
phases.append(phase)
122+
omegas.append(omega)
111123
# Get the dimensions of the current axis, which we will divide up
112124
#! TODO: Not current implemented; just use subplot for now
113125

@@ -134,10 +146,14 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
134146

135147
# Phase plot
136148
plt.subplot(212);
149+
if deg:
150+
phase_deg = phase
151+
else:
152+
phase_deg = phase * 180 / sp.pi
137153
if color==None:
138-
plt.semilogx(omega, phase)
154+
plt.semilogx(omega, phase_deg)
139155
else:
140-
plt.semilogx(omega, phase, color=color)
156+
plt.semilogx(omega, phase_deg, color=color)
141157
plt.hold(True)
142158

143159
# Add a grid to the plot
@@ -151,7 +167,10 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
151167
else:
152168
plt.xlabel("Frequency (rad/sec)")
153169

154-
return mag, phase, omega
170+
if len(syslist) == 1:
171+
return mags[0], phases[0], omegas[0]
172+
else:
173+
return mags, phases, omegas
155174

156175
# Nyquist plot
157176
def nyquist(syslist, omega=None, Plot=True):
@@ -274,6 +293,101 @@ def gangof4(P, C, omega=None):
274293
phase = np.squeeze(phase_tmp)
275294
plt.subplot(224); plt.loglog(omega, mag);
276295

296+
297+
# gain and phase margins
298+
# contributed by Sawyer B. Fuller <minster@caltech.edu>
299+
def margin(sysdata, deg=True):
300+
"""Calculate gain and phase margins and associated crossover frequencies
301+
302+
Usage:
303+
304+
gm, pm, sm, wg, wp, ws = margin(sysdata, deg=True)
305+
306+
Parameters
307+
----------
308+
sysdata: linsys or (mag, phase, omega) sequence
309+
sys : linsys
310+
Linear SISO system
311+
mag, phase, omega : sequence of array_like
312+
Input magnitude, phase, and frequencies (rad/sec) sequence from
313+
bode frequency response data
314+
deg=True: boolean
315+
If true, all input and output phases in degrees, else in radians
316+
317+
Returns
318+
-------
319+
gm, pm, sm, wg, wp, ws: float
320+
Gain margin gm, phase margin pm, stability margin sm, and
321+
associated crossover
322+
frequencies wg, wp, and ws of SISO open-loop. If more than
323+
one crossover frequency is detected, returns the lowest corresponding
324+
margin.
325+
"""
326+
#TODO do this precisely without the effects of discretization of frequencies?
327+
#TODO assumes SISO
328+
#TODO unit tests, margin plot
329+
330+
if (not getattr(sysdata, '__iter__', False)):
331+
sys = sysdata
332+
mag, phase, omega = bode(sys, deg=deg, Plot=False)
333+
elif len(sysdata) == 3:
334+
mag, phase, omega = sysdata
335+
else:
336+
raise ValueError("Margin sysdata must be either a linear system or a 3-sequence of mag, phase, omega.")
337+
338+
if deg:
339+
cycle = 360.
340+
crossover = 180.
341+
else:
342+
cycle = 2 * np.pi
343+
crossover = np.pi
344+
345+
wrapped_phase = -np.mod(phase, cycle)
346+
347+
# phase margin from minimum phase among all gain crossovers
348+
neg_mag_crossings_i = np.nonzero(np.diff(mag < 1) > 0)[0]
349+
mag_crossings_p = wrapped_phase[neg_mag_crossings_i]
350+
if len(neg_mag_crossings_i) == 0:
351+
if mag[0] < 1: # gain always less than one
352+
wp = np.nan
353+
pm = np.inf
354+
else: # gain always greater than one
355+
print "margin: no magnitude crossings found"
356+
wp = np.nan
357+
pm = np.nan
358+
else:
359+
min_mag_crossing_i = neg_mag_crossings_i[np.argmin(mag_crossings_p)]
360+
wp = omega[min_mag_crossing_i]
361+
pm = crossover + phase[min_mag_crossing_i]
362+
if pm < 0:
363+
print "warning: system unstable: negative phase margin"
364+
365+
# gain margin from minimum gain margin among all phase crossovers
366+
neg_phase_crossings_i = np.nonzero(np.diff(wrapped_phase < -crossover) > 0)[0]
367+
neg_phase_crossings_g = mag[neg_phase_crossings_i]
368+
if len(neg_phase_crossings_i) == 0:
369+
wg = np.nan
370+
gm = np.inf
371+
else:
372+
min_phase_crossing_i = neg_phase_crossings_i[
373+
np.argmax(neg_phase_crossings_g)]
374+
wg = omega[min_phase_crossing_i]
375+
gm = abs(1/mag[min_phase_crossing_i])
376+
if gm < 1:
377+
print "warning: system unstable: gain margin < 1"
378+
379+
# stability margin from minimum abs distance from -1 point
380+
if deg:
381+
phase_rad = phase * np.pi / 180.
382+
else:
383+
phase_rad = phase
384+
L = mag * np.exp(1j * phase_rad) # complex loop response to -1 pt
385+
min_Lplus1_i = np.argmin(np.abs(L + 1))
386+
sm = np.abs(L[min_Lplus1_i] + 1)
387+
ws = phase[min_Lplus1_i]
388+
389+
return gm, pm, sm, wg, wp, ws
390+
277391
#
278392
# Utility functions
279393
#
@@ -311,6 +425,10 @@ def default_frequency_range(syslist):
311425

312426
# Find the list of all poles and zeros in the systems
313427
features = np.array(())
428+
429+
# detect if single sys passed by checking if it is sequence-like
430+
if (not getattr(syslist, '__iter__', False)):
431+
syslist = (syslist,)
314432
for sys in syslist:
315433
# Add new features to the list
316434
features = np.concatenate((features, np.abs(sys.pole())))

src/matlab.py

+33
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,39 @@ def rlocus(sys, klist = None, **keywords):
781781
return rlist, klist
782782

783783

784+
def margin(*args):
785+
"""Calculate gain and phase margins and associated crossover frequencies
786+
787+
Usage:
788+
gm, pm, wg, wp = margin(sys)
789+
gm, pm, wg, wp = margin(mag,phase,w)
790+
791+
Parameters
792+
----------
793+
sys : linsys
794+
Linear SISO system
795+
mag, phase, w : array_like
796+
Input magnitude, phase (in deg.), and frequencies (rad/sec) from bode
797+
frequency response data
798+
799+
Returns
800+
-------
801+
gm, pm, wg, wp : float
802+
Gain margin gm, phase margin pm (in deg), and associated crossover
803+
frequencies wg and wp (in rad/sec) of SISO open-loop. If more than
804+
one crossover frequency is detected, returns the lowest corresponding
805+
margin.
806+
"""
807+
if len(args) == 1:
808+
sys = args[0]
809+
margins = freqplot.margin(sys)
810+
elif len(args) == 3:
811+
margins = freqplot.margin(args)
812+
else:
813+
raise ValueError("Margin needs 1 or 3 arguments; received %i."
814+
% len(args))
815+
816+
return margins[0], margins[1], margins[3], margins[4]
784817
#
785818
# Modifications to scipy.signal functions
786819
#

0 commit comments

Comments
 (0)