Skip to content

Commit c14b880

Browse files
committed
Merge pull request python-control#68 from repagh/master
Fixes to margin calculation
2 parents e22d364 + ebde9e4 commit c14b880

File tree

3 files changed

+202
-52
lines changed

3 files changed

+202
-52
lines changed

control/frdata.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ def __mul__(self, other):
220220

221221
# Convert the second argument to a transfer function.
222222
if isinstance(other, (int, float, complex)):
223-
return FRD(self.fresp * other, self.omega)
223+
return FRD(self.fresp * other, self.omega,
224+
smooth=(self.ifunc is not None))
224225
else:
225226
other = _convertToFRD(other, omega=self.omega)
226227

@@ -236,14 +237,17 @@ def __mul__(self, other):
236237
dtype=self.fresp.dtype)
237238
for i in range(len(self.omega)):
238239
fresp[:,:,i] = dot(self.fresp[:,:,i], other.fresp[:,:,i])
239-
return FRD(fresp, self.omega)
240+
return FRD(fresp, self.omega,
241+
smooth=(self.ifunc is not None) and
242+
(other.ifunc is not None))
240243

241244
def __rmul__(self, other):
242245
"""Right Multiply two LTI objects (serial connection)."""
243246

244247
# Convert the second argument to an frd function.
245248
if isinstance(other, (int, float, complex)):
246-
return FRD(self.fresp * other, self.omega)
249+
return FRD(self.fresp * other, self.omega,
250+
smooth=(self.ifunc is not None))
247251
else:
248252
other = _convertToFRD(other, omega=self.omega)
249253

@@ -260,14 +264,17 @@ def __rmul__(self, other):
260264
dtype=self.fresp.dtype)
261265
for i in range(len(self.omega)):
262266
fresp[:,:,i] = dot(other.fresp[:,:,i], self.fresp[:,:,i])
263-
return FRD(fresp, self.omega)
267+
return FRD(fresp, self.omega,
268+
smooth=(self.ifunc is not None) and
269+
(other.ifunc is not None))
264270

265271
# TODO: Division of MIMO transfer function objects is not written yet.
266272
def __truediv__(self, other):
267273
"""Divide two LTI objects."""
268274

269275
if isinstance(other, (int, float, complex)):
270-
return FRD(self.fresp * (1/other), self.omega)
276+
return FRD(self.fresp * (1/other), self.omega,
277+
smooth=(self.ifunc is not None))
271278
else:
272279
other = _convertToFRD(other, omega=self.omega)
273280

@@ -277,7 +284,9 @@ def __truediv__(self, other):
277284
raise NotImplementedError(
278285
"FRD.__truediv__ is currently implemented only for SISO systems.")
279286

280-
return FRD(self.fresp/other.fresp, self.omega)
287+
return FRD(self.fresp/other.fresp, self.omega,
288+
smooth=(self.ifunc is not None) and
289+
(other.ifunc is not None))
281290

282291
# TODO: Remove when transition to python3 complete
283292
def __div__(self, other):
@@ -287,7 +296,8 @@ def __div__(self, other):
287296
def __rtruediv__(self, other):
288297
"""Right divide two LTI objects."""
289298
if isinstance(other, (int, float, complex)):
290-
return FRD(other / self.fresp, self.omega)
299+
return FRD(other / self.fresp, self.omega,
300+
smooth=(self.ifunc is not None))
291301
else:
292302
other = _convertToFRD(other, omega=self.omega)
293303

@@ -306,7 +316,8 @@ def __pow__(self,other):
306316
if not type(other) == int:
307317
raise ValueError("Exponent must be an integer")
308318
if other == 0:
309-
return FRD(ones(self.fresp.shape),self.omega) #unity
319+
return FRD(ones(self.fresp.shape),self.omega,
320+
smooth=(self.ifunc is not None)) #unity
310321
if other > 0:
311322
return self * (self**(other-1))
312323
if other < 0:
@@ -338,7 +349,7 @@ def evalfr(self, omega):
338349
except:
339350
raise ValueError(
340351
"Frequency %f not in frequency list, try an interpolating"
341-
" FRD if you want additional points")
352+
" FRD if you want additional points" % omega)
342353
else:
343354
if getattr(omega, '__iter__', False):
344355
for i in range(self.outputs):
@@ -401,7 +412,7 @@ def feedback(self, other=1, sign=-1):
401412
self.fresp[:, :, k].view(type=matrix),
402413
eye(self.inputs))
403414

404-
return FRD(fresp, other.omega)
415+
return FRD(fresp, other.omega, smooth=(self.ifunc is not None))
405416

406417
def _convertToFRD(sys, omega, inputs=1, outputs=1):
407418
"""Convert a system to frequency response data form (if needed).
@@ -436,11 +447,11 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1):
436447
for k, w in enumerate(omega):
437448
fresp[:, :, k] = sys.evalfr(w)
438449

439-
return FRD(fresp, omega)
450+
return FRD(fresp, omega, smooth=True)
440451

441452
elif isinstance(sys, (int, float, complex)):
442453
fresp = ones((outputs, inputs, len(omega)), dtype=float)*sys
443-
return FRD(fresp, omega)
454+
return FRD(fresp, omega, smooth=True)
444455

445456
# try converting constant matrices
446457
try:
@@ -450,7 +461,7 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1):
450461
for i in range(outputs):
451462
for j in range(inputs):
452463
fresp[i,j,:] = sys[i,j]
453-
return FRD(fresp, omega)
464+
return FRD(fresp, omega, smooth=True)
454465
except:
455466
pass
456467

control/margins.py

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ def _polysqr(pol):
8484
#
8585
# RvP, July 8, 2014, corrected to exclude phase=0 crossing for the gain
8686
# margin polynomial
87-
def stability_margins(sysdata, returnall=False, epsw=1e-10):
87+
# RvP, July 8, 2015, augmented to calculate all phase/gain crossings with
88+
# frd data. Correct to return smallest phase
89+
# margin, smallest gain margin and their frequencies
90+
def stability_margins(sysdata, returnall=False, epsw=1e-8):
8891
"""Calculate stability margins and associated crossover frequencies.
8992
9093
Parameters
@@ -101,7 +104,7 @@ def stability_margins(sysdata, returnall=False, epsw=1e-10):
101104
minimum stability margins. For frequency data or FRD systems, only one
102105
margin is found and returned.
103106
epsw: float, optional
104-
Frequencies below this value (default 1e-10) are considered static gain,
107+
Frequencies below this value (default 1e-8) are considered static gain,
105108
and not returned as margin.
106109
107110
Returns
@@ -127,8 +130,8 @@ def stability_margins(sysdata, returnall=False, epsw=1e-10):
127130
sys = sysdata
128131
elif getattr(sysdata, '__iter__', False) and len(sysdata) == 3:
129132
mag, phase, omega = sysdata
130-
sys = frdata.FRD(mag * np.exp(1j * phase * np.pi/180), omega,
131-
smooth=True)
133+
sys = frdata.FRD(mag * np.exp(1j * phase * np.pi/180),
134+
omega, smooth=True)
132135
else:
133136
sys = xferfcn._convertToTransferFunction(sysdata)
134137
except Exception as e:
@@ -147,23 +150,27 @@ def stability_margins(sysdata, returnall=False, epsw=1e-10):
147150
rnum, inum = _polyimsplit(sys.num[0][0])
148151
rden, iden = _polyimsplit(sys.den[0][0])
149152

150-
# test imaginary part of tf == 0, for phase crossover/gain margins
153+
# test (imaginary part of tf) == 0, for phase crossover/gain margins
151154
test_w_180 = np.polyadd(np.polymul(inum, rden), np.polymul(rnum, -iden))
152155
w_180 = np.roots(test_w_180)
156+
#print ('1:w_180', w_180)
153157

154158
# first remove imaginary and negative frequencies, epsw removes the
155159
# "0" frequency for type-2 systems
156160
w_180 = np.real(w_180[(np.imag(w_180) == 0) * (w_180 >= epsw)])
161+
#print ('2:w_180', w_180)
157162

158163
# evaluate response at remaining frequencies, to test for phase 180 vs 0
159164
resp_w_180 = np.real(np.polyval(sys.num[0][0], 1.j*w_180) /
160165
np.polyval(sys.den[0][0], 1.j*w_180))
166+
#print ('resp_w_180', resp_w_180)
161167

162168
# only keep frequencies where the negative real axis is crossed
163-
w_180 = w_180[(resp_w_180 < 0.0)]
169+
w_180 = w_180[np.real(resp_w_180) < 0.0]
164170

165171
# and sort
166172
w_180.sort()
173+
#print ('3:w_180', w_180)
167174

168175
# test magnitude is 1 for gain crossover/phase margins
169176
test_wc = np.polysub(np.polyadd(_polysqr(rnum), _polysqr(inum)),
@@ -175,58 +182,91 @@ def stability_margins(sysdata, returnall=False, epsw=1e-10):
175182
# stability margin was a bitch to elaborate, relies on magnitude to
176183
# point -1, then take the derivative. Second derivative needs to be >0
177184
# to have a minimum
178-
test_wstabn = np.polyadd(_polysqr(rnum), _polysqr(inum))
179-
test_wstabd = np.polyadd(_polysqr(np.polyadd(rnum,rden)),
185+
test_wstabd = np.polyadd(_polysqr(rden), _polysqr(iden))
186+
test_wstabn = np.polyadd(_polysqr(np.polyadd(rnum,rden)),
180187
_polysqr(np.polyadd(inum,iden)))
181188
test_wstab = np.polysub(
182189
np.polymul(np.polyder(test_wstabn),test_wstabd),
183190
np.polymul(np.polyder(test_wstabd),test_wstabn))
184191

185-
# find the solutions
192+
# find the solutions, for positive omega, and only real ones
186193
wstab = np.roots(test_wstab)
194+
#print('wstabr', wstab)
195+
wstab = np.real(wstab[(np.imag(wstab) == 0) *
196+
(np.real(wstab) >= 0)])
197+
#print('wstab', wstab)
187198

188199
# and find the value of the 2nd derivative there, needs to be positive
189200
wstabplus = np.polyval(np.polyder(test_wstab), wstab)
201+
#print('wstabplus', wstabplus)
190202
wstab = np.real(wstab[(np.imag(wstab) == 0) * (wstab > epsw) *
191-
(np.abs(wstabplus) > 0.)])
203+
(wstabplus > 0.)])
204+
#print('wstab', wstab)
192205
wstab.sort()
193206

194207
else:
195208
# a bit coarse, have the interpolated frd evaluated again
196209
def mod(w):
197210
"""to give the function to calculate |G(jw)| = 1"""
198-
return [np.abs(sys.evalfr(w[0])[0][0]) - 1]
211+
return np.abs(sys.evalfr(w)[0][0]) - 1
199212

200213
def arg(w):
201214
"""function to calculate the phase angle at -180 deg"""
202-
return [np.angle(sys.evalfr(w[0])[0][0]) + np.pi]
215+
return np.angle(-sys.evalfr(w)[0][0])
203216

204217
def dstab(w):
205218
"""function to calculate the distance from -1 point"""
206-
return np.abs(sys.evalfr(w[0])[0][0] + 1.)
207-
208-
# how to calculate the frequency at which |G(jw)| = 1
209-
wc = np.array([sp.optimize.fsolve(mod, sys.omega[0])])[0]
210-
w_180 = np.array([sp.optimize.fsolve(arg, sys.omega[0])])[0]
211-
wstab = np.real(
212-
np.array([sp.optimize.fmin(dstab, sys.omega[0], disp=0)])[0])
219+
return np.abs(sys.evalfr(w)[0][0] + 1.)
220+
221+
# Find all crossings, note that this depends on omega having
222+
# a correct range
223+
widx = np.where(np.diff(np.sign(mod(sys.omega))))[0]
224+
wc = np.array(
225+
[ sp.optimize.brentq(mod, sys.omega[i], sys.omega[i+1])
226+
for i in widx if i+1 < len(sys.omega)])
227+
228+
# find the phase crossings ang(H(jw) == -180
229+
widx = np.where(np.diff(np.sign(arg(sys.omega))))[0]
230+
#print('widx (180)', widx, sys.omega[widx])
231+
#print('x', sys.evalfr(sys.omega[widx])[0][0])
232+
widx = widx[np.real(sys.evalfr(sys.omega[widx])[0][0]) <= 0]
233+
#print('widx (180,2)', widx)
234+
w_180 = np.array(
235+
[ sp.optimize.brentq(arg, sys.omega[i], sys.omega[i+1])
236+
for i in widx if i+1 < len(sys.omega) ])
237+
#print('x', sys.evalfr(w_180)[0][0])
238+
#print('w_180', w_180)
239+
240+
# find all stab margins?
241+
widx = np.where(np.diff(np.sign(np.diff(dstab(sys.omega)))))[0]
242+
#print('widx', widx)
243+
#print('wstabx', sys.omega[widx])
244+
wstab = np.array([ sp.optimize.minimize_scalar(
245+
dstab, bracket=(sys.omega[i], sys.omega[i+1])).x
246+
for i in widx if i+1 < len(sys.omega) and
247+
np.diff(np.diff(dstab(sys.omega[i-1:i+2])))[0] > 0 ])
248+
#print('wstabf0', wstab)
249+
wstab = wstab[(wstab >= sys.omega[0]) *
250+
(wstab <= sys.omega[-1])]
251+
#print ('wstabf', wstab)
252+
213253

214254
# margins, as iterables, converted frdata and xferfcn calculations to
215255
# vector for this
216-
PM = np.angle(sys.evalfr(wc)[0][0], deg=True) + 180
217-
GM = 1/(np.abs(sys.evalfr(w_180)[0][0]))
256+
GM = 1/np.abs(sys.evalfr(w_180)[0][0])
218257
SM = np.abs(sys.evalfr(wstab)[0][0]+1)
219-
258+
PM = np.angle(sys.evalfr(wc)[0][0], deg=True) + 180
259+
220260
if returnall:
221261
return GM, PM, SM, w_180, wc, wstab
222262
else:
223263
return (
224-
(GM.shape[0] or None) and GM[0],
225-
(PM.shape[0] or None) and PM[0],
226-
(SM.shape[0] or None) and SM[0],
227-
(w_180.shape[0] or None) and w_180[0],
228-
(wc.shape[0] or None) and wc[0],
229-
(wstab.shape[0] or None) and wstab[0])
264+
(GM.shape[0] or None) and np.amin(GM),
265+
(PM.shape[0] or None) and np.amin(PM),
266+
(SM.shape[0] or None) and np.amin(SM),
267+
(w_180.shape[0] or None) and w_180[GM==np.amin(GM)][0],
268+
(wc.shape[0] or None) and wc[PM==np.amin(PM)][0],
269+
(wstab.shape[0] or None) and wstab[SM==np.amin(SM)][0])
230270

231271

232272
# Contributed by Steffen Waldherr <waldherr@ist.uni-stuttgart.de>
@@ -291,11 +331,12 @@ def margin(*args):
291331
Returns
292332
-------
293333
gm, pm, Wcg, Wcp : float
294-
Gain margin gm, phase margin pm (in deg), gain crossover frequency
295-
(corresponding to phase margin) and phase crossover frequency
296-
(corresponding to gain margin), in rad/sec of SISO open-loop.
297-
If more than one crossover frequency is detected, returns the lowest
298-
corresponding margin.
334+
Gain margin gm, phase margin pm (in deg), phase crossover frequency
335+
(corresponding to gain margin, where phase=-180) and gain crossover
336+
frequency (corresponding to phase margin, where gain is 0dB),
337+
in rad/sec of SISO open-loop.
338+
If more than one crossover frequency is detected for gain or phase,
339+
this returns one with the lowest corresponding margin.
299340
300341
Examples
301342
--------

0 commit comments

Comments
 (0)