Skip to content

Commit 2cf1545

Browse files
committed
Further progress/debugging on disk margin calculation + plot utility
1 parent 9d55419 commit 2cf1545

2 files changed

Lines changed: 481 additions & 194 deletions

File tree

control/margins.py

Lines changed: 148 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ def margin(*args):
533533

534534
return margin[0], margin[1], margin[3], margin[4]
535535

536-
def disk_margins(L, omega, skew = 0.0):
536+
def disk_margins(L, omega, skew = 0.0, returnall = False):
537537
"""Compute disk-based stability margins for SISO or MIMO LTI system.
538538
539539
Parameters
@@ -546,17 +546,21 @@ def disk_margins(L, omega, skew = 0.0):
546546
skew = 0 uses the "balanced" sensitivity function 0.5*(S - T)
547547
skew = 1 uses the sensitivity function S
548548
skew = -1 uses the complementary sensitivity function T
549+
returnall : bool, optional
550+
If true, return all margins found. If False (default), return only the
551+
minimum stability margins. Only margins in the given frequency region
552+
can be found and returned.
549553
550554
Returns
551555
-------
552556
DM : ndarray
553-
1d array of frequency-dependent disk margins. DM is the same
557+
1D array of frequency-dependent disk margins. DM is the same
554558
size as "omega" parameter.
555559
GM : ndarray
556-
1d array of frequency-dependent disk-based gain margins, in dB.
560+
1D array of frequency-dependent disk-based gain margins, in dB.
557561
GM is the same size as "omega" parameter.
558562
PM : ndarray
559-
1d array of frequency-dependent disk-based phase margins, in deg.
563+
1D array of frequency-dependent disk-based phase margins, in deg.
560564
PM is the same size as "omega" parameter.
561565
562566
Examples
@@ -567,13 +571,15 @@ def disk_margins(L, omega, skew = 0.0):
567571
>> import matplotlib.pyplot as plt
568572
>>
569573
>> omega = np.logspace(-1, 3, 1001)
574+
>>
570575
>> P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]])
571576
>> K = control.ss([],[],[], [[1, -2], [0, 1]])
572577
>> L = P*K
573-
>> DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T)
574-
>> print(f"min(DM) = {min(DM)}")
575-
>> print(f"min(GM) = {min(GM)} dB")
576-
>> print(f"min(PM) = {min(PM)} deg")
578+
>>
579+
>> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T)
580+
>> print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})")
581+
>> print(f"GM = {GM[np.argmin(DM)]} dB")
582+
>> print(f"PM = {PM[np.argmin(DM)]} deg\n")
577583
>>
578584
>> plt.figure(1)
579585
>> plt.subplot(3,1,1)
@@ -587,7 +593,7 @@ def disk_margins(L, omega, skew = 0.0):
587593
>> plt.figure(1)
588594
>> plt.subplot(3,1,2)
589595
>> plt.semilogx(omega, GM, label='$\\gamma_{m}$')
590-
>> plt.ylabel('Margin (dB)')
596+
>> plt.ylabel('Gain Margin (dB)')
591597
>> plt.legend()
592598
>> plt.title('Disk-Based Gain Margin')
593599
>> plt.grid()
@@ -598,7 +604,7 @@ def disk_margins(L, omega, skew = 0.0):
598604
>> plt.figure(1)
599605
>> plt.subplot(3,1,3)
600606
>> plt.semilogx(omega, PM, label='$\\phi_{m}$')
601-
>> plt.ylabel('Margin (deg)')
607+
>> plt.ylabel('Phase Margin (deg)')
602608
>> plt.legend()
603609
>> plt.title('Disk-Based Phase Margin')
604610
>> plt.grid()
@@ -640,7 +646,7 @@ def disk_margins(L, omega, skew = 0.0):
640646

641647
# Compute frequency response of the "balanced" (according
642648
# to the skew parameter "sigma") sensitivity function [1-2]
643-
ST = S + (skew - 1)*I/2
649+
ST = S + 0.5*(skew - 1)*I
644650
ST_mag, ST_phase, _ = ST.frequency_response(omega)
645651
ST_jw = (ST_mag*np.exp(1j*ST_phase))
646652
if not L.issiso():
@@ -650,63 +656,156 @@ def disk_margins(L, omega, skew = 0.0):
650656
# the structured singular value, a.k.a. "mu", of (S + (skew - 1)/2).
651657
# Uses SLICOT routine AB13MD to compute. [1,3-4].
652658
DM = np.zeros(omega.shape, np.float64)
653-
GM = np.zeros(omega.shape, np.float64)
654-
PM = np.zeros(omega.shape, np.float64)
659+
DGM = np.zeros(omega.shape, np.float64)
660+
DPM = np.zeros(omega.shape, np.float64)
655661
for ii in range(0,len(omega)):
656662
# Disk margin (a.k.a. "alpha") vs. frequency
657663
if L.issiso() and (ab13md == None):
658-
#TODO: replace with unstructured singular value
659-
DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0]
664+
DM[ii] = np.minimum(1e5,
665+
1.0/bode(ST_jw, omega = omega[ii], plot = False)[0])
660666
else:
661-
DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0]
662-
663-
# Gain-only margin (dB) vs. frequency
664-
gamma_min = (1 - DM[ii]*(1 - skew)/2)/(1 + DM[ii]*(1 + skew)/2)
665-
gamma_max = (1 + DM[ii]*(1 - skew)/2)/(1 - DM[ii]*(1 + skew)/2)
666-
GM[ii] = mag2db(np.minimum(1/gamma_min, gamma_max))
667+
DM[ii] = np.minimum(1e5,
668+
1.0/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0])
669+
670+
with np.errstate(divide = 'ignore', invalid = 'ignore'):
671+
# Real-axis intercepts with the disk
672+
gamma_min = (1 - 0.5*DM[ii]*(1 - skew))/(1 + 0.5*DM[ii]*(1 + skew))
673+
gamma_max = (1 + 0.5*DM[ii]*(1 - skew))/(1 - 0.5*DM[ii]*(1 + skew))
674+
675+
# Gain margin (dB)
676+
DGM[ii] = mag2db(np.minimum(1/gamma_min, gamma_max))
677+
if np.isnan(DGM[ii]):
678+
DGM[ii] = float('inf')
679+
680+
# Phase margin (deg)
681+
if np.isinf(gamma_max):
682+
DPM[ii] = 90.0
683+
else:
684+
DPM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max)
685+
if abs(DPM[ii]) >= 1.0:
686+
DPM[ii] = float('Inf')
687+
else:
688+
DPM[ii] = np.rad2deg(np.arccos(DPM[ii]))
667689

668-
# Phase-only margin (deg) vs. frequency
669-
if math.isinf(gamma_max):
670-
PM[ii] = 90.0
690+
if returnall:
691+
# Frequency-dependent disk margin, gain margin and phase margin
692+
return (DM, DGM, DPM)
693+
else:
694+
# Worst-case disk margin, gain margin and phase margin
695+
if DGM.shape[0] and not np.isinf(DGM).all():
696+
with np.errstate(all='ignore'):
697+
gmidx = np.where(np.abs(DGM) == np.min(np.abs(DGM)))
671698
else:
672-
PM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max)
673-
if PM[ii] >= 1.0:
674-
PM[ii] = 0.0
675-
elif PM[ii] <= -1.0:
676-
PM[ii] = float('Inf')
677-
else:
678-
PM[ii] = np.rad2deg(np.arccos(PM[ii]))
699+
gmidx = -1
700+
if DPM.shape[0]:
701+
pmidx = np.where(np.abs(DPM) == np.amin(np.abs(DPM)))[0]
679702

680-
return (DM, GM, PM)
703+
return ((not DM.shape[0] and float('inf')) or np.amin(DM),
704+
(not gmidx != -1 and float('inf')) or DGM[gmidx][0],
705+
(not DPM.shape[0] and float('inf')) or DPM[pmidx][0])
681706

682-
def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, shade = True, shade_alpha = 0.1):
683-
"""TODO: docstring
684-
"""
707+
def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500,
708+
shade = True, shade_alpha = 0.25):
709+
"""Compute disk-based stability margins for SISO or MIMO LTI system.
685710
686-
# Complex bounding curve of stable gain/phase variations
687-
theta = np.linspace(0, np.pi, ntheta)
688-
f = (2 + alpha_max*(1 - skew)*np.exp(1j*theta))/\
689-
(2 - alpha_max*(1 + skew)*np.exp(1j*theta))
711+
Parameters
712+
----------
713+
L : SISO or MIMO LTI system representing the loop transfer function
714+
omega : ndarray
715+
1d array of (non-negative) frequencies (rad/s) at which to evaluate
716+
the disk-based stability margins
717+
skew : (optional, default = 0) skew parameter for disk margin calculation.
718+
skew = 0 uses the "balanced" sensitivity function 0.5*(S - T)
719+
skew = 1 uses the sensitivity function S
720+
skew = -1 uses the complementary sensitivity function T
721+
returnall : bool, optional
722+
If true, return all margins found. If False (default), return only the
723+
minimum stability margins. Only margins in the given frequency region
724+
can be found and returned.
725+
726+
Returns
727+
-------
728+
DM : ndarray
729+
1D array of frequency-dependent disk margins. DM is the same
730+
size as "omega" parameter.
731+
GM : ndarray
732+
1D array of frequency-dependent disk-based gain margins, in dB.
733+
GM is the same size as "omega" parameter.
734+
PM : ndarray
735+
1D array of frequency-dependent disk-based phase margins, in deg.
736+
PM is the same size as "omega" parameter.
737+
738+
Examples
739+
--------
740+
>> import control
741+
>> import numpy as np
742+
>> import matplotlib
743+
>> import matplotlib.pyplot as plt
744+
>>
745+
>> omega = np.logspace(-1, 2, 1001)
746+
>>
747+
>> s = control.tf('s') # Laplace variable
748+
>> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop transfer function
749+
>> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0,) # balanced (S - T)
750+
>>
751+
>> plt.figure(1)
752+
>> disk_margin_plot(0.75, skew = [0.0, 1.0, -1.0])
753+
>> plt.show()
754+
755+
References
756+
----------
757+
[1] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction
758+
to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40,
759+
no. 5 (October 2020): 78-95.
760+
761+
"""
690762

691763
# Create axis if needed
692764
if ax is None:
693765
ax = plt.gca()
694766

695-
# Plot the allowable complex "disk" of gain/phase variations
696-
gamma_dB = mag2db(np.abs(f)) # gain margin (dB)
697-
phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg)
698-
if shade:
699-
out = ax.plot(gamma_dB, phi_deg, alpha=shade_alpha, label='_nolegend_')
700-
x1 = ax.lines[0].get_xydata()[:,0]
701-
y1 = ax.lines[0].get_xydata()[:,1]
702-
ax.fill_between(x1,y1, alpha = shade_alpha)
767+
# Allow scalar or vector arguments (to overlay plots)
768+
if np.isscalar(alpha_max):
769+
alpha_max = np.asarray([alpha_max])
770+
else:
771+
alpha_max = np.asarray(alpha_max)
772+
773+
if np.isscalar(skew):
774+
skew = np.asarray([skew])
703775
else:
704-
out = ax.plot(gamma_dB, phi_deg)
776+
skew = np.asarray(skew)
777+
778+
779+
theta = np.linspace(0, np.pi, ntheta)
780+
legend_list = []
781+
for ii in range(0, skew.shape[0]):
782+
legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %(skew[ii], alpha_max[ii])
783+
legend_list.append(legend_str)
784+
785+
# Complex bounding curve of stable gain/phase variations
786+
f = (2 + alpha_max[ii]*(1 - skew[ii])*np.exp(1j*theta))/\
787+
(2 - alpha_max[ii]*(1 + skew[ii])*np.exp(1j*theta))
788+
789+
# Allowable combined gain/phase variations
790+
gamma_dB = mag2db(np.abs(f)) # gain margin (dB)
791+
phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg)
792+
793+
# Plot the allowable combined gain/phase variations
794+
if shade:
795+
out = ax.plot(gamma_dB, phi_deg,
796+
alpha = shade_alpha, label = '_nolegend_')
797+
ax.fill_between(
798+
ax.lines[ii].get_xydata()[:,0],
799+
ax.lines[ii].get_xydata()[:,1],
800+
alpha = shade_alpha)
801+
else:
802+
out = ax.plot(gamma_dB, phi_deg)
705803

706804
plt.ylabel('Gain Variation (dB)')
707805
plt.xlabel('Phase Variation (deg)')
708806
plt.title('Range of Gain and Phase Variations')
807+
plt.legend(legend_list)
709808
plt.grid()
710809
plt.tight_layout()
711810

712-
return out
811+
return out

0 commit comments

Comments
 (0)