55
55
#
56
56
57
57
# 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 ):
59
60
"""Bode plot for a system
60
61
61
62
Usage
62
63
=====
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)
64
65
65
66
Plots a Bode plot for the system over a (optional) frequency range.
66
67
@@ -74,23 +75,30 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
74
75
If True, plot result in dB
75
76
Hz : boolean
76
77
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)
77
82
Plot : boolean
78
83
If True, plot magnitude and phase
79
84
80
85
Return values
81
86
-------------
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)
85
90
86
91
Notes
87
92
-----
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.
89
96
"""
90
97
# If argument was a singleton, turn it into a list
91
98
if (not getattr (syslist , '__iter__' , False )):
92
99
syslist = (syslist ,)
93
100
101
+ mags , phases , omegas = [], [], []
94
102
for sys in syslist :
95
103
if (sys .inputs > 1 or sys .outputs > 1 ):
96
104
#TODO: Add MIMO bode plots.
@@ -104,10 +112,14 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
104
112
mag_tmp , phase_tmp , omega = sys .freqresp (omega )
105
113
mag = np .squeeze (mag_tmp )
106
114
phase = np .squeeze (phase_tmp )
115
+ phase = unwrap (phase )
107
116
if Hz : omega = omega / (2 * sp .pi )
108
117
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 )
111
123
# Get the dimensions of the current axis, which we will divide up
112
124
#! TODO: Not current implemented; just use subplot for now
113
125
@@ -134,10 +146,14 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
134
146
135
147
# Phase plot
136
148
plt .subplot (212 );
149
+ if deg :
150
+ phase_deg = phase
151
+ else :
152
+ phase_deg = phase * 180 / sp .pi
137
153
if color == None :
138
- plt .semilogx (omega , phase )
154
+ plt .semilogx (omega , phase_deg )
139
155
else :
140
- plt .semilogx (omega , phase , color = color )
156
+ plt .semilogx (omega , phase_deg , color = color )
141
157
plt .hold (True )
142
158
143
159
# Add a grid to the plot
@@ -151,7 +167,10 @@ def bode(syslist, omega=None, dB=False, Hz=False, color=None, Plot=True):
151
167
else :
152
168
plt .xlabel ("Frequency (rad/sec)" )
153
169
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
155
174
156
175
# Nyquist plot
157
176
def nyquist (syslist , omega = None , Plot = True ):
@@ -274,6 +293,101 @@ def gangof4(P, C, omega=None):
274
293
phase = np .squeeze (phase_tmp )
275
294
plt .subplot (224 ); plt .loglog (omega , mag );
276
295
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
+
277
391
#
278
392
# Utility functions
279
393
#
@@ -311,6 +425,10 @@ def default_frequency_range(syslist):
311
425
312
426
# Find the list of all poles and zeros in the systems
313
427
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 ,)
314
432
for sys in syslist :
315
433
# Add new features to the list
316
434
features = np .concatenate ((features , np .abs (sys .pole ())))
0 commit comments