@@ -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