From f6dada2b7fb5a683169abb05faddd23d818a1389 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 11 Jan 2025 01:12:52 -0500 Subject: [PATCH 01/34] Initial version of disk margin calculation and example/test script --- control/margins.py | 157 ++++++++++++++++++++- examples/test_margins.py | 288 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 examples/test_margins.py diff --git a/control/margins.py b/control/margins.py index 301baaf57..0fb450448 100644 --- a/control/margins.py +++ b/control/margins.py @@ -57,9 +57,23 @@ from . import frdata from . import freqplot from .exception import ControlMIMONotImplemented - -__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin'] - +from . import ss +try: + from slycot import ab13md +except ImportError: + ab13md = None +try: + from . import mag2db +except: + # Likely the following: + # + # ImportError: cannot import name 'mag2db' from partially initialized module + # 'control' (most likely due to a circular import) (control/__init__.py) + # + def mag2db(mag): + return 20*np.log10(mag) + +__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin', 'disk_margins'] # private helper functions def _poly_iw(sys): @@ -501,6 +515,143 @@ def phase_crossover_frequencies(sys): return omega, gain +def disk_margins(L, omega, skew = 0.0): + """Compute disk-based stability margins for SISO or MIMO LTI system. + + Parameters + ---------- + L : SISO or MIMO LTI system representing the loop transfer function + omega : ndarray + 1d array of (non-negative) frequencies at which to evaluate + the disk-based stability margins + skew : (optional, default = 0) skew parameter for disk margin calculation. + skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) + skew = -1 uses the sensitivity function S + skew = 1 uses the complementary sensitivity function T + + Returns + ------- + DM : ndarray + 1d array of frequency-dependent disk margins. DM is the same + size as "omega" parameter. + GM : ndarray + 1d array of frequency-dependent disk-based gain margins, in dB. + GM is the same size as "omega" parameter. + PM : ndarray + 1d array of frequency-dependent disk-based phase margins, in deg. + PM is the same size as "omega" parameter. + + Examples + -------- + >> import control + >> import numpy as np + >> import matplotlib + >> import matplotlib.pyplot as plt + >> + >> omega = np.logspace(-1, 3, 1001) + >> P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) + >> K = control.ss([],[],[], [[1, -2], [0, 1]]) + >> L = P*K + >> DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + >> print(f"min(DM) = {min(DM)}") + >> print(f"min(GM) = {min(GM)} dB") + >> print(f"min(PM) = {min(PM)} deg") + >> + >> plt.figure(1) + >> plt.subplot(3,1,1) + >> plt.semilogx(omega, DM, label='$\\alpha$') + >> plt.legend() + >> plt.title('Disk Margin (Outputs)') + >> plt.grid() + >> plt.tight_layout() + >> plt.xlim([omega[0], omega[-1]]) + >> + >> plt.figure(1) + >> plt.subplot(3,1,2) + >> plt.semilogx(omega, GM, label='$\\gamma_{m}$') + >> plt.ylabel('Margin (dB)') + >> plt.legend() + >> plt.title('Disk-Based Gain Margin (Outputs)') + >> plt.grid() + >> plt.ylim([0, 40]) + >> plt.tight_layout() + >> plt.xlim([omega[0], omega[-1]]) + >> + >> plt.figure(1) + >> plt.subplot(3,1,3) + >> plt.semilogx(omega, PM, label='$\\phi_{m}$') + >> plt.ylabel('Margin (deg)') + >> plt.legend() + >> plt.title('Disk-Based Phase Margin (Outputs)') + >> plt.grid() + >> plt.ylim([0, 90]) + >> plt.tight_layout() + >> plt.xlim([omega[0], omega[-1]]) + + References + ---------- + [1] Blight, James D., R. Lane Dailey, and Dagfinn Gangsaas. “Practical + Control Law Design for Aircraft Using Multivariable Techniques.” + International Journal of Control 59, no. 1 (January 1994): 93-137. + https://doi.org/10.1080/00207179408923071. + + [2] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction + to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, + no. 5 (October 2020): 78-95. + + [3] P. Benner, V. Mehrmann, V. Sima, S. Van Huffel, and A. Varga, "SLICOT + - A Subroutine Library in Systems and Control Theory", Applied and + Computational Control, Signals, and Circuits (Birkhauser), Vol. 1, Ch. + 10, pp. 505-546, 1999. + + [4] S. Van Huffel, V. Sima, A. Varga, S. Hammarling, and F. Delebecque, + "Development of High Performance Numerical Software for Control", IEEE + Control Systems Magazine, Vol. 24, Nr. 1, Feb., pp. 60-76, 2004. + """ + + # Get dimensions of feedback system + ny,_ = ss(L).C.shape + I = ss([], [], [], np.eye(ny)) + + # Loop sensitivity function + S = I.feedback(L) + + # Compute frequency response of the "balanced" (according + # to the skew parameter "sigma") sensitivity function [1-2] + ST = S + (skew - 1)*I/2 + ST_mag, ST_phase, _ = ST.frequency_response(omega) + ST_jw = (ST_mag*np.exp(1j*ST_phase)) + if not L.issiso(): + ST_jw = ST_jw.transpose(2,0,1) + + # Frequency-dependent complex disk margin, computed using upper bound of + # the structured singular value, a.k.a. "mu", of (S + (skew - 1)/2). + # Uses SLICOT routine AB13MD to compute. [1,3-4]. + DM = np.zeros(omega.shape, np.float64) + GM = np.zeros(omega.shape, np.float64) + PM = np.zeros(omega.shape, np.float64) + for ii in range(0,len(omega)): + # Disk margin (magnitude) vs. frequency + DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] + + # Gain-only margin (dB) vs. frequency + gamma_min = (1 - DM[ii]*(1 - skew)/2)/(1 + DM[ii]*(1 + skew)/2) + gamma_max = (1 + DM[ii]*(1 - skew)/2)/(1 - DM[ii]*(1 + skew)/2) + GM[ii] = mag2db(np.minimum(1/gamma_min, gamma_max)) + + # Phase-only margin (deg) vs. frequency + if math.isinf(gamma_max): + PM[ii] = 90.0 + else: + PM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max) + if PM[ii] >= 1.0: + PM[ii] = 0.0 # np.arccos(1.0) + elif PM[ii] <= -1.0: + PM[ii] = float('Inf') # np.arccos(-1.0) + else: + PM[ii] = np.rad2deg(np.arccos(PM[ii])) + + return (DM, GM, PM) def margin(*args): """margin(sysdata) diff --git a/examples/test_margins.py b/examples/test_margins.py new file mode 100644 index 000000000..727820d59 --- /dev/null +++ b/examples/test_margins.py @@ -0,0 +1,288 @@ +"""test_margins.py +Demonstrate disk-based stability margin calculations. +""" + +import os, sys, math +import numpy as np +import matplotlib.pyplot as plt +import control + +if __name__ == '__main__': + + # Frequencies of interest + omega = np.logspace(-1, 3, 1001) + + # Plant model + P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) + + # Feedback controller + K = control.ss([],[],[], [[1, -2], [0, 1]]) + + # Output loop gain + L = P*K + print(f"Lo = {L}") + + print(f"------------- Balanced sensitivity function (S - T) -------------") + DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {control.db2mag(min(GM))}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n\n") + + plt.figure(1) + plt.subplot(3,3,1) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin (Outputs)') + plt.grid() + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + plt.figure(1) + plt.subplot(3,3,4) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Margin (dB)') + plt.legend() + plt.title('Disk-Based Gain Margin (Outputs)') + plt.grid() + plt.ylim([0, 40]) + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + plt.figure(1) + plt.subplot(3,3,7) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Margin (deg)') + plt.legend() + plt.title('Disk-Based Phase Margin (Outputs)') + plt.grid() + plt.ylim([0, 90]) + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + #print(f"------------- Sensitivity function (S) -------------") + #DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + #print(f"min(DM) = {min(DM)}") + #print(f"min(GM) = {control.db2mag(min(GM))}") + #print(f"min(GM) = {min(GM)} dB") + #print(f"min(PM) = {min(PM)} deg\n\n") + + #print(f"------------- Complementary sensitivity function (T) -------------") + #DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + #print(f"min(DM) = {min(DM)}") + #print(f"min(GM) = {control.db2mag(min(GM))}") + #print(f"min(GM) = {min(GM)} dB") + #print(f"min(PM) = {min(PM)} deg\n\n") + + # Input loop gain + L = K*P + print(f"Li = {L}") + + print(f"------------- Balanced sensitivity function (S - T) -------------") + DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {control.db2mag(min(GM))}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n\n") + + plt.figure(1) + plt.subplot(3,3,2) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin (Inputs)') + plt.grid() + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + plt.figure(1) + plt.subplot(3,3,5) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Margin (dB)') + plt.legend() + plt.title('Disk-Based Gain Margin (Inputs)') + plt.grid() + plt.ylim([0, 40]) + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + plt.figure(1) + plt.subplot(3,3,8) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Margin (deg)') + plt.legend() + plt.title('Disk-Based Phase Margin (Inputs)') + plt.grid() + plt.ylim([0, 90]) + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + #print(f"------------- Sensitivity function (S) -------------") + #DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + #print(f"min(DM) = {min(DM)}") + #print(f"min(GM) = {control.db2mag(min(GM))}") + #print(f"min(GM) = {min(GM)} dB") + #print(f"min(PM) = {min(PM)} deg\n\n") + + #print(f"------------- Complementary sensitivity function (T) -------------") + #DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + #print(f"min(DM) = {min(DM)}") + #print(f"min(GM) = {control.db2mag(min(GM))}") + #print(f"min(GM) = {min(GM)} dB") + #print(f"min(PM) = {min(PM)} deg\n\n") + + # Input/output loop gain + L = control.append(P*K, K*P) + print(f"L = {L}") + + print(f"------------- Balanced sensitivity function (S - T) -------------") + DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {control.db2mag(min(GM))}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n\n") + + plt.figure(1) + plt.subplot(3,3,3) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin (Inputs)') + plt.grid() + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + plt.figure(1) + plt.subplot(3,3,6) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Margin (dB)') + plt.legend() + plt.title('Disk-Based Gain Margin (Inputs)') + plt.grid() + plt.ylim([0, 40]) + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + plt.figure(1) + plt.subplot(3,3,9) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Margin (deg)') + plt.legend() + plt.title('Disk-Based Phase Margin (Inputs)') + plt.grid() + plt.ylim([0, 90]) + plt.tight_layout() + plt.xlim([omega[0], omega[-1]]) + + #print(f"------------- Sensitivity function (S) -------------") + #DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + #print(f"min(DM) = {min(DM)}") + #print(f"min(GM) = {control.db2mag(min(GM))}") + #print(f"min(GM) = {min(GM)} dB") + #print(f"min(PM) = {min(PM)} deg\n\n") + + #print(f"------------- Complementary sensitivity function (T) -------------") + #DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + #print(f"min(DM) = {min(DM)}") + #print(f"min(GM) = {control.db2mag(min(GM))}") + #print(f"min(GM) = {min(GM)} dB") + #print(f"min(PM) = {min(PM)} deg\n\n") + + if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + plt.show() + + sys.exit(0) + + # Laplace variable + s = control.tf('s') + + # Disk-based stability margins for example SISO loop transfer function(s) + L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) + L = 6.25/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) + print(f"L = {L}\n\n") + + print(f"------------- Balanced sensitivity function (S - T) -------------") + DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {np.db2mag(min(GM))}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n\n") + + print(f"------------- Sensitivity function (S) -------------") + DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {np.db2mag(min(GM))}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n\n") + + print(f"------------- Complementary sensitivity function (T) -------------") + DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {np.db2mag(min(GM))}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n\n") + + print(f"------------- Python control built-in -------------") + GM_, PM_, SM_ = control.margins.stability_margins(L)[:3] # python-control default (S-based...?) + print(f"SM_ = {SM_}") + print(f"GM_ = {GM_} dB") + print(f"PM_ = {PM_} deg") + + plt.figure(1) + plt.subplot(2,3,1) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin') + plt.grid() + + plt.figure(1) + plt.subplot(2,3,2) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Margin (dB)') + plt.legend() + plt.title('Gain-Only Margin') + plt.grid() + plt.ylim([0, 16]) + + plt.figure(1) + plt.subplot(2,3,3) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Margin (deg)') + plt.legend() + plt.title('Phase-Only Margin') + plt.grid() + plt.ylim([0, 180]) + + # Disk-based stability margins for example MIMO loop transfer function(s) + P = control.tf([[[0, 1, -1],[0, 1, 1]],[[0, -1, 1],[0, 1, -1]]], + [[[1, 0, 1],[1, 0, 1]],[[1, 0, 1],[1, 0, 1]]]) + K = control.ss([],[],[],[[-1, 0], [0, -1]]) + L = control.ss(P*K) + print(f"L = {L}") + DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced + print(f"min(DM) = {min(DM)}") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg") + + plt.figure(1) + plt.subplot(2,3,4) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.xlabel('Frequency (rad/s)') + plt.legend() + plt.grid() + + plt.figure(1) + plt.subplot(2,3,5) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.xlabel('Frequency (rad/s)') + plt.ylabel('Margin (dB)') + plt.legend() + plt.grid() + plt.ylim([0, 16]) + + plt.figure(1) + plt.subplot(2,3,6) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.xlabel('Frequency (rad/s)') + plt.ylabel('Margin (deg)') + plt.legend() + plt.grid() + plt.ylim([0, 180]) From e46f824dd924b9b7ad2adfb283333341a18de819 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 11 Jan 2025 01:28:16 -0500 Subject: [PATCH 02/34] Comment updates: update margins.py header, clarify import exception handler comment, fix typo in skew description of disk_margins docstring --- control/margins.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/control/margins.py b/control/margins.py index 0fb450448..b42d45ffb 100644 --- a/control/margins.py +++ b/control/margins.py @@ -7,6 +7,7 @@ margins.stability_margins margins.phase_crossover_frequencies margins.margin +margins.disk_margins """ """Copyright (c) 2011 by California Institute of Technology @@ -64,8 +65,8 @@ ab13md = None try: from . import mag2db -except: - # Likely the following: +except ImportError: + # Likely due the following circular import issue: # # ImportError: cannot import name 'mag2db' from partially initialized module # 'control' (most likely due to a circular import) (control/__init__.py) @@ -526,8 +527,8 @@ def disk_margins(L, omega, skew = 0.0): the disk-based stability margins skew : (optional, default = 0) skew parameter for disk margin calculation. skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) - skew = -1 uses the sensitivity function S - skew = 1 uses the complementary sensitivity function T + skew = 1 uses the sensitivity function S + skew = -1 uses the complementary sensitivity function T Returns ------- From 1e3af88cc9bfaf7ad3a27986b65ebf5458a6e644 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 12 Jan 2025 18:11:43 -0500 Subject: [PATCH 03/34] More work in progress on disk margin calculation, adding new prototype function to plot allowable gain/phase variations. --- control/margins.py | 152 ++++++++++++++++++++++++--------------- examples/test_margins.py | 34 +++++---- 2 files changed, 115 insertions(+), 71 deletions(-) diff --git a/control/margins.py b/control/margins.py index b42d45ffb..0a4b120d3 100644 --- a/control/margins.py +++ b/control/margins.py @@ -49,6 +49,8 @@ """ import math +import matplotlib as mpl +import matplotlib.pyplot as plt from warnings import warn import numpy as np import scipy as sp @@ -516,6 +518,56 @@ def phase_crossover_frequencies(sys): return omega, gain +def margin(*args): + """margin(sysdata) + + Calculate gain and phase margins and associated crossover frequencies. + + Parameters + ---------- + sysdata : LTI system or (mag, phase, omega) sequence + sys : StateSpace or TransferFunction + Linear SISO system representing the loop transfer function + mag, phase, omega : sequence of array_like + Input magnitude, phase (in deg.), and frequencies (rad/sec) from + bode frequency response data + + Returns + ------- + gm : float + Gain margin + pm : float + Phase margin (in degrees) + wcg : float or array_like + Crossover frequency associated with gain margin (phase crossover + frequency), where phase crosses below -180 degrees. + wcp : float or array_like + Crossover frequency associated with phase margin (gain crossover + frequency), where gain crosses below 1. + + Margins are calculated for a SISO open-loop system. + + If there is more than one gain crossover, the one at the smallest margin + (deviation from gain = 1), in absolute sense, is returned. Likewise the + smallest phase margin (in absolute sense) is returned. + + Examples + -------- + >>> G = ct.tf(1, [1, 2, 1, 0]) + >>> gm, pm, wcg, wcp = ct.margin(G) + + """ + if len(args) == 1: + sys = args[0] + margin = stability_margins(sys) + elif len(args) == 3: + margin = stability_margins(args) + else: + raise ValueError("Margin needs 1 or 3 arguments; received %i." + % len(args)) + + return margin[0], margin[1], margin[3], margin[4] + def disk_margins(L, omega, skew = 0.0): """Compute disk-based stability margins for SISO or MIMO LTI system. @@ -523,7 +575,7 @@ def disk_margins(L, omega, skew = 0.0): ---------- L : SISO or MIMO LTI system representing the loop transfer function omega : ndarray - 1d array of (non-negative) frequencies at which to evaluate + 1d array of (non-negative) frequencies (rad/s) at which to evaluate the disk-based stability margins skew : (optional, default = 0) skew parameter for disk margin calculation. skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) @@ -562,7 +614,7 @@ def disk_margins(L, omega, skew = 0.0): >> plt.subplot(3,1,1) >> plt.semilogx(omega, DM, label='$\\alpha$') >> plt.legend() - >> plt.title('Disk Margin (Outputs)') + >> plt.title('Disk Margin') >> plt.grid() >> plt.tight_layout() >> plt.xlim([omega[0], omega[-1]]) @@ -572,7 +624,7 @@ def disk_margins(L, omega, skew = 0.0): >> plt.semilogx(omega, GM, label='$\\gamma_{m}$') >> plt.ylabel('Margin (dB)') >> plt.legend() - >> plt.title('Disk-Based Gain Margin (Outputs)') + >> plt.title('Disk-Based Gain Margin') >> plt.grid() >> plt.ylim([0, 40]) >> plt.tight_layout() @@ -583,7 +635,7 @@ def disk_margins(L, omega, skew = 0.0): >> plt.semilogx(omega, PM, label='$\\phi_{m}$') >> plt.ylabel('Margin (deg)') >> plt.legend() - >> plt.title('Disk-Based Phase Margin (Outputs)') + >> plt.title('Disk-Based Phase Margin') >> plt.grid() >> plt.ylim([0, 90]) >> plt.tight_layout() @@ -610,6 +662,10 @@ def disk_margins(L, omega, skew = 0.0): Control Systems Magazine, Vol. 24, Nr. 1, Feb., pp. 60-76, 2004. """ + # Check for prerequisites + if (not L.issiso()) and (ab13md == None): + raise ControlMIMONotImplemented("Need slycot to compute MIMO disk_margins") + # Get dimensions of feedback system ny,_ = ss(L).C.shape I = ss([], [], [], np.eye(ny)) @@ -632,8 +688,12 @@ def disk_margins(L, omega, skew = 0.0): GM = np.zeros(omega.shape, np.float64) PM = np.zeros(omega.shape, np.float64) for ii in range(0,len(omega)): - # Disk margin (magnitude) vs. frequency - DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] + # Disk margin (a.k.a. "alpha") vs. frequency + if L.issiso() and (ab13md == None): + #TODO: replace with unstructured singular value + DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] + else: + DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] # Gain-only margin (dB) vs. frequency gamma_min = (1 - DM[ii]*(1 - skew)/2)/(1 + DM[ii]*(1 + skew)/2) @@ -646,60 +706,38 @@ def disk_margins(L, omega, skew = 0.0): else: PM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max) if PM[ii] >= 1.0: - PM[ii] = 0.0 # np.arccos(1.0) + PM[ii] = 0.0 elif PM[ii] <= -1.0: - PM[ii] = float('Inf') # np.arccos(-1.0) + PM[ii] = float('Inf') else: PM[ii] = np.rad2deg(np.arccos(PM[ii])) return (DM, GM, PM) -def margin(*args): - """margin(sysdata) - - Calculate gain and phase margins and associated crossover frequencies. - - Parameters - ---------- - sysdata : LTI system or (mag, phase, omega) sequence - sys : StateSpace or TransferFunction - Linear SISO system representing the loop transfer function - mag, phase, omega : sequence of array_like - Input magnitude, phase (in deg.), and frequencies (rad/sec) from - bode frequency response data - - Returns - ------- - gm : float - Gain margin - pm : float - Phase margin (in degrees) - wcg : float or array_like - Crossover frequency associated with gain margin (phase crossover - frequency), where phase crosses below -180 degrees. - wcp : float or array_like - Crossover frequency associated with phase margin (gain crossover - frequency), where gain crosses below 1. - - Margins are calculated for a SISO open-loop system. - - If there is more than one gain crossover, the one at the smallest margin - (deviation from gain = 1), in absolute sense, is returned. Likewise the - smallest phase margin (in absolute sense) is returned. - - Examples - -------- - >>> G = ct.tf(1, [1, 2, 1, 0]) - >>> gm, pm, wcg, wcp = ct.margin(G) - - """ - if len(args) == 1: - sys = args[0] - margin = stability_margins(sys) - elif len(args) == 3: - margin = stability_margins(args) - else: - raise ValueError("Margin needs 1 or 3 arguments; received %i." - % len(args)) - - return margin[0], margin[1], margin[3], margin[4] +def disk_margin_plot(DM_jw, skew = 0.0, ax = None, alpha = 0.3): + # Smallest (worst-case) disk margin within frequencies of interest + DM_min = min(DM_jw) # worst-case + + # Complex bounding curve of stable gain/phase variations + theta = np.linspace(0, np.pi, 500) + f = (2 + DM_min*(1 - skew)*np.exp(1j*theta))/\ + (2 - DM_min*(1 - skew)*np.exp(1j*theta)) + + # Create axis if needed + if ax is None: + ax = plt.gca() + + # Plot the allowable complex "disk" of gain/phase variations + gamma_dB = mag2db(np.abs(f)) # gain margin (dB) + phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) + out = ax.plot(gamma_dB, phi_deg, alpha=0.3, label='_nolegend_') + x1 = ax.lines[0].get_xydata()[:,0] + y1 = ax.lines[0].get_xydata()[:,1] + ax.fill_between(x1,y1, alpha = alpha) + plt.ylabel('Gain Variation (dB)') + plt.xlabel('Phase Variation (deg)') + plt.title('Range of Gain and Phase Variations') + plt.grid() + plt.tight_layout() + + return out \ No newline at end of file diff --git a/examples/test_margins.py b/examples/test_margins.py index 727820d59..e72dc793c 100644 --- a/examples/test_margins.py +++ b/examples/test_margins.py @@ -23,7 +23,7 @@ print(f"Lo = {L}") print(f"------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {control.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") @@ -61,14 +61,14 @@ plt.xlim([omega[0], omega[-1]]) #print(f"------------- Sensitivity function (S) -------------") - #DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") #print(f"------------- Complementary sensitivity function (T) -------------") - #DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") @@ -79,7 +79,7 @@ print(f"Li = {L}") print(f"------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {control.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") @@ -117,25 +117,25 @@ plt.xlim([omega[0], omega[-1]]) #print(f"------------- Sensitivity function (S) -------------") - #DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") #print(f"------------- Complementary sensitivity function (T) -------------") - #DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") # Input/output loop gain - L = control.append(P*K, K*P) + L = control.append(P, K) print(f"L = {L}") print(f"------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {control.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") @@ -172,15 +172,21 @@ plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.figure(2) + control.margins.disk_margin_plot(DM, -1.0) # S-based (S) + control.margins.disk_margin_plot(DM, 1.0) # T-based (T) + control.margins.disk_margin_plot(DM, 0.0) # balanced (S - T) + plt.legend(['$\\sigma$ = -1.0','$\\sigma$ = 1.0','$\\sigma$ = 0.0']) + #print(f"------------- Sensitivity function (S) -------------") - #DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") #print(f"------------- Complementary sensitivity function (T) -------------") - #DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") @@ -200,21 +206,21 @@ print(f"L = {L}\n\n") print(f"------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) + DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {np.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n\n") print(f"------------- Sensitivity function (S) -------------") - DM, GM, PM = control.disk_margins(L, omega, 1.0) # S-based (S) + DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {np.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n\n") print(f"------------- Complementary sensitivity function (T) -------------") - DM, GM, PM = control.disk_margins(L, omega, -1.0) # T-based (T) + DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {np.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") @@ -257,7 +263,7 @@ K = control.ss([],[],[],[[-1, 0], [0, -1]]) L = control.ss(P*K) print(f"L = {L}") - DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced + DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced print(f"min(DM) = {min(DM)}") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg") From ba157895fee83ecc15bd5c1bcd8f56f4e50778a5 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 12 Jan 2025 18:14:19 -0500 Subject: [PATCH 04/34] Add disk_margin_plot to subroutine list in comment header in margins.py --- control/margins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/control/margins.py b/control/margins.py index 0a4b120d3..c5fa437d0 100644 --- a/control/margins.py +++ b/control/margins.py @@ -8,6 +8,7 @@ margins.phase_crossover_frequencies margins.margin margins.disk_margins +margins.disk_margin_plot """ """Copyright (c) 2011 by California Institute of Technology From e47ae02dad355468eafa1005fd580fc866b58725 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 12 Jan 2025 18:15:27 -0500 Subject: [PATCH 05/34] Follow-on to ba157895fee83ecc15bd5c1bcd8f56f4e50778a5, add disk_margin_plot to list of functions within the margins module --- control/margins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/margins.py b/control/margins.py index c5fa437d0..aa3849801 100644 --- a/control/margins.py +++ b/control/margins.py @@ -77,7 +77,7 @@ def mag2db(mag): return 20*np.log10(mag) -__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin', 'disk_margins'] +__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin', 'disk_margins', 'disk_margin_plot'] # private helper functions def _poly_iw(sys): From f84221dcb1dbd5c978eef0f61be32d263b6fba37 Mon Sep 17 00:00:00 2001 From: Josiah Date: Tue, 14 Jan 2025 08:04:12 -0500 Subject: [PATCH 06/34] More work in progress on disk_margin_plot. Corrected a typo/bug in the calculation of 'f', the bounding complex curve. Seems to look correct for balanced (skew = 0) case, still verifying the skewed equivalent. --- control/margins.py | 24 +++++----- examples/test_margins.py | 94 ++++++++++++++-------------------------- 2 files changed, 46 insertions(+), 72 deletions(-) diff --git a/control/margins.py b/control/margins.py index aa3849801..5af19b310 100644 --- a/control/margins.py +++ b/control/margins.py @@ -715,14 +715,14 @@ def disk_margins(L, omega, skew = 0.0): return (DM, GM, PM) -def disk_margin_plot(DM_jw, skew = 0.0, ax = None, alpha = 0.3): - # Smallest (worst-case) disk margin within frequencies of interest - DM_min = min(DM_jw) # worst-case +def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, shade = True, shade_alpha = 0.1): + """TODO: docstring + """ # Complex bounding curve of stable gain/phase variations - theta = np.linspace(0, np.pi, 500) - f = (2 + DM_min*(1 - skew)*np.exp(1j*theta))/\ - (2 - DM_min*(1 - skew)*np.exp(1j*theta)) + theta = np.linspace(0, np.pi, ntheta) + f = (2 + alpha_max*(1 - skew)*np.exp(1j*theta))/\ + (2 - alpha_max*(1 + skew)*np.exp(1j*theta)) # Create axis if needed if ax is None: @@ -731,10 +731,14 @@ def disk_margin_plot(DM_jw, skew = 0.0, ax = None, alpha = 0.3): # Plot the allowable complex "disk" of gain/phase variations gamma_dB = mag2db(np.abs(f)) # gain margin (dB) phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) - out = ax.plot(gamma_dB, phi_deg, alpha=0.3, label='_nolegend_') - x1 = ax.lines[0].get_xydata()[:,0] - y1 = ax.lines[0].get_xydata()[:,1] - ax.fill_between(x1,y1, alpha = alpha) + if shade: + out = ax.plot(gamma_dB, phi_deg, alpha=shade_alpha, label='_nolegend_') + x1 = ax.lines[0].get_xydata()[:,0] + y1 = ax.lines[0].get_xydata()[:,1] + ax.fill_between(x1,y1, alpha = shade_alpha) + else: + out = ax.plot(gamma_dB, phi_deg) + plt.ylabel('Gain Variation (dB)') plt.xlabel('Phase Variation (deg)') plt.title('Range of Gain and Phase Variations') diff --git a/examples/test_margins.py b/examples/test_margins.py index e72dc793c..1236921db 100644 --- a/examples/test_margins.py +++ b/examples/test_margins.py @@ -6,6 +6,10 @@ import numpy as np import matplotlib.pyplot as plt import control +try: + from slycot import ab13md +except ImportError: + ab13md = None if __name__ == '__main__': @@ -20,14 +24,14 @@ # Output loop gain L = P*K - print(f"Lo = {L}") + #print(f"Lo = {L}") - print(f"------------- Balanced sensitivity function (S - T) -------------") + print(f"------------- Balanced sensitivity function (S - T), outputs -------------") DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {control.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg\n\n") + print(f"min(PM) = {min(PM)} deg\n") plt.figure(1) plt.subplot(3,3,1) @@ -60,14 +64,14 @@ plt.tight_layout() plt.xlim([omega[0], omega[-1]]) - #print(f"------------- Sensitivity function (S) -------------") + #print(f"------------- Sensitivity function (S), outputs -------------") #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") - #print(f"------------- Complementary sensitivity function (T) -------------") + #print(f"------------- Complementary sensitivity function (T), outputs -------------") #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") @@ -76,14 +80,14 @@ # Input loop gain L = K*P - print(f"Li = {L}") + #print(f"Li = {L}") - print(f"------------- Balanced sensitivity function (S - T) -------------") + print(f"------------- Balanced sensitivity function (S - T), inputs -------------") DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {control.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg\n\n") + print(f"min(PM) = {min(PM)} deg\n") plt.figure(1) plt.subplot(3,3,2) @@ -116,14 +120,14 @@ plt.tight_layout() plt.xlim([omega[0], omega[-1]]) - #print(f"------------- Sensitivity function (S) -------------") + #print(f"------------- Sensitivity function (S), inputs -------------") #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") - #print(f"------------- Complementary sensitivity function (T) -------------") + #print(f"------------- Complementary sensitivity function (T), inputs -------------") #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") @@ -131,15 +135,15 @@ #print(f"min(PM) = {min(PM)} deg\n\n") # Input/output loop gain - L = control.append(P, K) - print(f"L = {L}") + L = control.parallel(P, K) + #print(f"L = {L}") - print(f"------------- Balanced sensitivity function (S - T) -------------") + print(f"------------- Balanced sensitivity function (S - T), inputs and outputs -------------") DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {control.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg\n\n") + print(f"min(PM) = {min(PM)} deg\n") plt.figure(1) plt.subplot(3,3,3) @@ -173,19 +177,21 @@ plt.xlim([omega[0], omega[-1]]) plt.figure(2) - control.margins.disk_margin_plot(DM, -1.0) # S-based (S) - control.margins.disk_margin_plot(DM, 1.0) # T-based (T) - control.margins.disk_margin_plot(DM, 0.0) # balanced (S - T) - plt.legend(['$\\sigma$ = -1.0','$\\sigma$ = 1.0','$\\sigma$ = 0.0']) - - #print(f"------------- Sensitivity function (S) -------------") + control.margins.disk_margin_plot(min(DM), -2.0) # S-based (S) + control.margins.disk_margin_plot(min(DM), 0.0) # balanced (S - T) + control.margins.disk_margin_plot(min(DM), 2.0) # T-based (T) + plt.legend(['$\\sigma$ = -2.0','$\\sigma$ = 0.0','$\\sigma$ = 2.0']) + plt.xlim([-8, 8]) + plt.ylim([0, 35]) + + #print(f"------------- Sensitivity function (S), inputs and outputs -------------") #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") #print(f"min(GM) = {min(GM)} dB") #print(f"min(PM) = {min(PM)} deg\n\n") - #print(f"------------- Complementary sensitivity function (T) -------------") + #print(f"------------- Complementary sensitivity function (T), inputs and outputs -------------") #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) #print(f"min(DM) = {min(DM)}") #print(f"min(GM) = {control.db2mag(min(GM))}") @@ -203,31 +209,31 @@ # Disk-based stability margins for example SISO loop transfer function(s) L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) L = 6.25/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) - print(f"L = {L}\n\n") + #print(f"L = {L}") print(f"------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {np.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg\n\n") + print(f"min(PM) = {min(PM)} deg\n") print(f"------------- Sensitivity function (S) -------------") DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {np.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg\n\n") + print(f"min(PM) = {min(PM)} deg\n") print(f"------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) print(f"min(DM) = {min(DM)}") print(f"min(GM) = {np.db2mag(min(GM))}") print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg\n\n") + print(f"min(PM) = {min(PM)} deg\n") print(f"------------- Python control built-in -------------") - GM_, PM_, SM_ = control.margins.stability_margins(L)[:3] # python-control default (S-based...?) + GM_, PM_, SM_ = stability_margins(L)[:3] # python-control default (S-based...?) print(f"SM_ = {SM_}") print(f"GM_ = {GM_} dB") print(f"PM_ = {PM_} deg") @@ -256,39 +262,3 @@ plt.title('Phase-Only Margin') plt.grid() plt.ylim([0, 180]) - - # Disk-based stability margins for example MIMO loop transfer function(s) - P = control.tf([[[0, 1, -1],[0, 1, 1]],[[0, -1, 1],[0, 1, -1]]], - [[[1, 0, 1],[1, 0, 1]],[[1, 0, 1],[1, 0, 1]]]) - K = control.ss([],[],[],[[-1, 0], [0, -1]]) - L = control.ss(P*K) - print(f"L = {L}") - DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {min(GM)} dB") - print(f"min(PM) = {min(PM)} deg") - - plt.figure(1) - plt.subplot(2,3,4) - plt.semilogx(omega, DM, label='$\\alpha$') - plt.xlabel('Frequency (rad/s)') - plt.legend() - plt.grid() - - plt.figure(1) - plt.subplot(2,3,5) - plt.semilogx(omega, GM, label='$\\gamma_{m}$') - plt.xlabel('Frequency (rad/s)') - plt.ylabel('Margin (dB)') - plt.legend() - plt.grid() - plt.ylim([0, 16]) - - plt.figure(1) - plt.subplot(2,3,6) - plt.semilogx(omega, PM, label='$\\phi_{m}$') - plt.xlabel('Frequency (rad/s)') - plt.ylabel('Margin (deg)') - plt.legend() - plt.grid() - plt.ylim([0, 180]) From 2cf1545244b64f309fae6c3f56a65e4aae68bebb Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 20 Apr 2025 22:27:48 -0400 Subject: [PATCH 07/34] Further progress/debugging on disk margin calculation + plot utility --- control/margins.py | 197 ++++++++++++---- examples/test_margins.py | 478 +++++++++++++++++++++++++++------------ 2 files changed, 481 insertions(+), 194 deletions(-) diff --git a/control/margins.py b/control/margins.py index ba23a80a9..dfd3552af 100644 --- a/control/margins.py +++ b/control/margins.py @@ -533,7 +533,7 @@ def margin(*args): return margin[0], margin[1], margin[3], margin[4] -def disk_margins(L, omega, skew = 0.0): +def disk_margins(L, omega, skew = 0.0, returnall = False): """Compute disk-based stability margins for SISO or MIMO LTI system. Parameters @@ -546,17 +546,21 @@ def disk_margins(L, omega, skew = 0.0): skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) skew = 1 uses the sensitivity function S skew = -1 uses the complementary sensitivity function T + returnall : bool, optional + If true, return all margins found. If False (default), return only the + minimum stability margins. Only margins in the given frequency region + can be found and returned. Returns ------- DM : ndarray - 1d array of frequency-dependent disk margins. DM is the same + 1D array of frequency-dependent disk margins. DM is the same size as "omega" parameter. GM : ndarray - 1d array of frequency-dependent disk-based gain margins, in dB. + 1D array of frequency-dependent disk-based gain margins, in dB. GM is the same size as "omega" parameter. PM : ndarray - 1d array of frequency-dependent disk-based phase margins, in deg. + 1D array of frequency-dependent disk-based phase margins, in deg. PM is the same size as "omega" parameter. Examples @@ -567,13 +571,15 @@ def disk_margins(L, omega, skew = 0.0): >> import matplotlib.pyplot as plt >> >> omega = np.logspace(-1, 3, 1001) + >> >> P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) >> K = control.ss([],[],[], [[1, -2], [0, 1]]) >> L = P*K - >> DM, GM, PM = control.disk_margins(L, omega, 0.0) # balanced (S - T) - >> print(f"min(DM) = {min(DM)}") - >> print(f"min(GM) = {min(GM)} dB") - >> print(f"min(PM) = {min(PM)} deg") + >> + >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + >> print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + >> print(f"GM = {GM[np.argmin(DM)]} dB") + >> print(f"PM = {PM[np.argmin(DM)]} deg\n") >> >> plt.figure(1) >> plt.subplot(3,1,1) @@ -587,7 +593,7 @@ def disk_margins(L, omega, skew = 0.0): >> plt.figure(1) >> plt.subplot(3,1,2) >> plt.semilogx(omega, GM, label='$\\gamma_{m}$') - >> plt.ylabel('Margin (dB)') + >> plt.ylabel('Gain Margin (dB)') >> plt.legend() >> plt.title('Disk-Based Gain Margin') >> plt.grid() @@ -598,7 +604,7 @@ def disk_margins(L, omega, skew = 0.0): >> plt.figure(1) >> plt.subplot(3,1,3) >> plt.semilogx(omega, PM, label='$\\phi_{m}$') - >> plt.ylabel('Margin (deg)') + >> plt.ylabel('Phase Margin (deg)') >> plt.legend() >> plt.title('Disk-Based Phase Margin') >> plt.grid() @@ -640,7 +646,7 @@ def disk_margins(L, omega, skew = 0.0): # Compute frequency response of the "balanced" (according # to the skew parameter "sigma") sensitivity function [1-2] - ST = S + (skew - 1)*I/2 + ST = S + 0.5*(skew - 1)*I ST_mag, ST_phase, _ = ST.frequency_response(omega) ST_jw = (ST_mag*np.exp(1j*ST_phase)) if not L.issiso(): @@ -650,63 +656,156 @@ def disk_margins(L, omega, skew = 0.0): # the structured singular value, a.k.a. "mu", of (S + (skew - 1)/2). # Uses SLICOT routine AB13MD to compute. [1,3-4]. DM = np.zeros(omega.shape, np.float64) - GM = np.zeros(omega.shape, np.float64) - PM = np.zeros(omega.shape, np.float64) + DGM = np.zeros(omega.shape, np.float64) + DPM = np.zeros(omega.shape, np.float64) for ii in range(0,len(omega)): # Disk margin (a.k.a. "alpha") vs. frequency if L.issiso() and (ab13md == None): - #TODO: replace with unstructured singular value - DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] + DM[ii] = np.minimum(1e5, + 1.0/bode(ST_jw, omega = omega[ii], plot = False)[0]) else: - DM[ii] = 1/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] - - # Gain-only margin (dB) vs. frequency - gamma_min = (1 - DM[ii]*(1 - skew)/2)/(1 + DM[ii]*(1 + skew)/2) - gamma_max = (1 + DM[ii]*(1 - skew)/2)/(1 - DM[ii]*(1 + skew)/2) - GM[ii] = mag2db(np.minimum(1/gamma_min, gamma_max)) + DM[ii] = np.minimum(1e5, + 1.0/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0]) + + with np.errstate(divide = 'ignore', invalid = 'ignore'): + # Real-axis intercepts with the disk + gamma_min = (1 - 0.5*DM[ii]*(1 - skew))/(1 + 0.5*DM[ii]*(1 + skew)) + gamma_max = (1 + 0.5*DM[ii]*(1 - skew))/(1 - 0.5*DM[ii]*(1 + skew)) + + # Gain margin (dB) + DGM[ii] = mag2db(np.minimum(1/gamma_min, gamma_max)) + if np.isnan(DGM[ii]): + DGM[ii] = float('inf') + + # Phase margin (deg) + if np.isinf(gamma_max): + DPM[ii] = 90.0 + else: + DPM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max) + if abs(DPM[ii]) >= 1.0: + DPM[ii] = float('Inf') + else: + DPM[ii] = np.rad2deg(np.arccos(DPM[ii])) - # Phase-only margin (deg) vs. frequency - if math.isinf(gamma_max): - PM[ii] = 90.0 + if returnall: + # Frequency-dependent disk margin, gain margin and phase margin + return (DM, DGM, DPM) + else: + # Worst-case disk margin, gain margin and phase margin + if DGM.shape[0] and not np.isinf(DGM).all(): + with np.errstate(all='ignore'): + gmidx = np.where(np.abs(DGM) == np.min(np.abs(DGM))) else: - PM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max) - if PM[ii] >= 1.0: - PM[ii] = 0.0 - elif PM[ii] <= -1.0: - PM[ii] = float('Inf') - else: - PM[ii] = np.rad2deg(np.arccos(PM[ii])) + gmidx = -1 + if DPM.shape[0]: + pmidx = np.where(np.abs(DPM) == np.amin(np.abs(DPM)))[0] - return (DM, GM, PM) + return ((not DM.shape[0] and float('inf')) or np.amin(DM), + (not gmidx != -1 and float('inf')) or DGM[gmidx][0], + (not DPM.shape[0] and float('inf')) or DPM[pmidx][0]) -def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, shade = True, shade_alpha = 0.1): - """TODO: docstring - """ +def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, + shade = True, shade_alpha = 0.25): + """Compute disk-based stability margins for SISO or MIMO LTI system. - # Complex bounding curve of stable gain/phase variations - theta = np.linspace(0, np.pi, ntheta) - f = (2 + alpha_max*(1 - skew)*np.exp(1j*theta))/\ - (2 - alpha_max*(1 + skew)*np.exp(1j*theta)) + Parameters + ---------- + L : SISO or MIMO LTI system representing the loop transfer function + omega : ndarray + 1d array of (non-negative) frequencies (rad/s) at which to evaluate + the disk-based stability margins + skew : (optional, default = 0) skew parameter for disk margin calculation. + skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) + skew = 1 uses the sensitivity function S + skew = -1 uses the complementary sensitivity function T + returnall : bool, optional + If true, return all margins found. If False (default), return only the + minimum stability margins. Only margins in the given frequency region + can be found and returned. + + Returns + ------- + DM : ndarray + 1D array of frequency-dependent disk margins. DM is the same + size as "omega" parameter. + GM : ndarray + 1D array of frequency-dependent disk-based gain margins, in dB. + GM is the same size as "omega" parameter. + PM : ndarray + 1D array of frequency-dependent disk-based phase margins, in deg. + PM is the same size as "omega" parameter. + + Examples + -------- + >> import control + >> import numpy as np + >> import matplotlib + >> import matplotlib.pyplot as plt + >> + >> omega = np.logspace(-1, 2, 1001) + >> + >> s = control.tf('s') # Laplace variable + >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop transfer function + >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0,) # balanced (S - T) + >> + >> plt.figure(1) + >> disk_margin_plot(0.75, skew = [0.0, 1.0, -1.0]) + >> plt.show() + + References + ---------- + [1] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction + to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, + no. 5 (October 2020): 78-95. + + """ # Create axis if needed if ax is None: ax = plt.gca() - # Plot the allowable complex "disk" of gain/phase variations - gamma_dB = mag2db(np.abs(f)) # gain margin (dB) - phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) - if shade: - out = ax.plot(gamma_dB, phi_deg, alpha=shade_alpha, label='_nolegend_') - x1 = ax.lines[0].get_xydata()[:,0] - y1 = ax.lines[0].get_xydata()[:,1] - ax.fill_between(x1,y1, alpha = shade_alpha) + # Allow scalar or vector arguments (to overlay plots) + if np.isscalar(alpha_max): + alpha_max = np.asarray([alpha_max]) + else: + alpha_max = np.asarray(alpha_max) + + if np.isscalar(skew): + skew = np.asarray([skew]) else: - out = ax.plot(gamma_dB, phi_deg) + skew = np.asarray(skew) + + + theta = np.linspace(0, np.pi, ntheta) + legend_list = [] + for ii in range(0, skew.shape[0]): + legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %(skew[ii], alpha_max[ii]) + legend_list.append(legend_str) + + # Complex bounding curve of stable gain/phase variations + f = (2 + alpha_max[ii]*(1 - skew[ii])*np.exp(1j*theta))/\ + (2 - alpha_max[ii]*(1 + skew[ii])*np.exp(1j*theta)) + + # Allowable combined gain/phase variations + gamma_dB = mag2db(np.abs(f)) # gain margin (dB) + phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) + + # Plot the allowable combined gain/phase variations + if shade: + out = ax.plot(gamma_dB, phi_deg, + alpha = shade_alpha, label = '_nolegend_') + ax.fill_between( + ax.lines[ii].get_xydata()[:,0], + ax.lines[ii].get_xydata()[:,1], + alpha = shade_alpha) + else: + out = ax.plot(gamma_dB, phi_deg) plt.ylabel('Gain Variation (dB)') plt.xlabel('Phase Variation (deg)') plt.title('Range of Gain and Phase Variations') + plt.legend(legend_list) plt.grid() plt.tight_layout() - return out \ No newline at end of file + return out diff --git a/examples/test_margins.py b/examples/test_margins.py index 1236921db..a6725a032 100644 --- a/examples/test_margins.py +++ b/examples/test_margins.py @@ -6,30 +6,41 @@ import numpy as np import matplotlib.pyplot as plt import control -try: - from slycot import ab13md -except ImportError: - ab13md = None -if __name__ == '__main__': +import math +import matplotlib as mpl +import matplotlib.pyplot as plt +from warnings import warn + +import numpy as np +import scipy as sp + +def test_siso1(): + # + # Disk-based stability margins for example + # SISO loop transfer function(s) + # # Frequencies of interest - omega = np.logspace(-1, 3, 1001) + omega = np.logspace(-1, 2, 1001) - # Plant model - P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) + # Laplace variable + s = control.tf('s') - # Feedback controller - K = control.ss([],[],[], [[1, -2], [0, 1]]) + # Loop transfer gain + L = control.tf(25, [1, 10, 10, 10]) - # Output loop gain - L = P*K - #print(f"Lo = {L}") + print(f"------------- Python control built-in (S) -------------") + GM_, PM_, SM_ = control.stability_margins(L)[:3] # python-control default (S-based...?) + print(f"SM_ = {SM_}") + print(f"GM_ = {GM_} dB") + print(f"PM_ = {PM_} deg\n") - print(f"------------- Balanced sensitivity function (S - T), outputs -------------") - DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {control.db2mag(min(GM))}") + print(f"------------- Sensitivity function (S) -------------") + DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n") @@ -37,55 +48,36 @@ plt.subplot(3,3,1) plt.semilogx(omega, DM, label='$\\alpha$') plt.legend() - plt.title('Disk Margin (Outputs)') + plt.title('Disk Margin') plt.grid() - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) plt.figure(1) plt.subplot(3,3,4) plt.semilogx(omega, GM, label='$\\gamma_{m}$') - plt.ylabel('Margin (dB)') + plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Disk-Based Gain Margin (Outputs)') + plt.title('Gain-Only Margin') plt.grid() - plt.ylim([0, 40]) - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) plt.figure(1) plt.subplot(3,3,7) plt.semilogx(omega, PM, label='$\\phi_{m}$') - plt.ylabel('Margin (deg)') + plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Disk-Based Phase Margin (Outputs)') + plt.title('Phase-Only Margin') plt.grid() - plt.ylim([0, 90]) - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) - #print(f"------------- Sensitivity function (S), outputs -------------") - #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) - #print(f"min(DM) = {min(DM)}") - #print(f"min(GM) = {control.db2mag(min(GM))}") - #print(f"min(GM) = {min(GM)} dB") - #print(f"min(PM) = {min(PM)} deg\n\n") - - #print(f"------------- Complementary sensitivity function (T), outputs -------------") - #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) - #print(f"min(DM) = {min(DM)}") - #print(f"min(GM) = {control.db2mag(min(GM))}") - #print(f"min(GM) = {min(GM)} dB") - #print(f"min(PM) = {min(PM)} deg\n\n") - - # Input loop gain - L = K*P - #print(f"Li = {L}") - - print(f"------------- Balanced sensitivity function (S - T), inputs -------------") - DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {control.db2mag(min(GM))}") + print(f"------------- Complementary sensitivity function (T) -------------") + DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n") @@ -93,55 +85,36 @@ plt.subplot(3,3,2) plt.semilogx(omega, DM, label='$\\alpha$') plt.legend() - plt.title('Disk Margin (Inputs)') + plt.title('Disk Margin') plt.grid() - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) plt.figure(1) plt.subplot(3,3,5) plt.semilogx(omega, GM, label='$\\gamma_{m}$') - plt.ylabel('Margin (dB)') + plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Disk-Based Gain Margin (Inputs)') + plt.title('Gain-Only Margin') plt.grid() - plt.ylim([0, 40]) - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) plt.figure(1) plt.subplot(3,3,8) plt.semilogx(omega, PM, label='$\\phi_{m}$') - plt.ylabel('Margin (deg)') + plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Disk-Based Phase Margin (Inputs)') + plt.title('Phase-Only Margin') plt.grid() - plt.ylim([0, 90]) - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) - #print(f"------------- Sensitivity function (S), inputs -------------") - #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) - #print(f"min(DM) = {min(DM)}") - #print(f"min(GM) = {control.db2mag(min(GM))}") - #print(f"min(GM) = {min(GM)} dB") - #print(f"min(PM) = {min(PM)} deg\n\n") - - #print(f"------------- Complementary sensitivity function (T), inputs -------------") - #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) - #print(f"min(DM) = {min(DM)}") - #print(f"min(GM) = {control.db2mag(min(GM))}") - #print(f"min(GM) = {min(GM)} dB") - #print(f"min(PM) = {min(PM)} deg\n\n") - - # Input/output loop gain - L = control.parallel(P, K) - #print(f"L = {L}") - - print(f"------------- Balanced sensitivity function (S - T), inputs and outputs -------------") - DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {control.db2mag(min(GM))}") + print(f"------------- Balanced sensitivity function (S - T) -------------") + DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n") @@ -149,116 +122,331 @@ plt.subplot(3,3,3) plt.semilogx(omega, DM, label='$\\alpha$') plt.legend() - plt.title('Disk Margin (Inputs)') + plt.title('Disk Margin') plt.grid() - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) plt.figure(1) plt.subplot(3,3,6) plt.semilogx(omega, GM, label='$\\gamma_{m}$') - plt.ylabel('Margin (dB)') + plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Disk-Based Gain Margin (Inputs)') + plt.title('Gain-Only Margin') plt.grid() - plt.ylim([0, 40]) - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) plt.figure(1) plt.subplot(3,3,9) plt.semilogx(omega, PM, label='$\\phi_{m}$') - plt.ylabel('Margin (deg)') + plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Disk-Based Phase Margin (Inputs)') + plt.title('Phase-Only Margin') plt.grid() - plt.ylim([0, 90]) - plt.tight_layout() plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) - plt.figure(2) - control.margins.disk_margin_plot(min(DM), -2.0) # S-based (S) - control.margins.disk_margin_plot(min(DM), 0.0) # balanced (S - T) - control.margins.disk_margin_plot(min(DM), 2.0) # T-based (T) - plt.legend(['$\\sigma$ = -2.0','$\\sigma$ = 0.0','$\\sigma$ = 2.0']) - plt.xlim([-8, 8]) - plt.ylim([0, 35]) - - #print(f"------------- Sensitivity function (S), inputs and outputs -------------") - #DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) - #print(f"min(DM) = {min(DM)}") - #print(f"min(GM) = {control.db2mag(min(GM))}") - #print(f"min(GM) = {min(GM)} dB") - #print(f"min(PM) = {min(PM)} deg\n\n") - - #print(f"------------- Complementary sensitivity function (T), inputs and outputs -------------") - #DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) - #print(f"min(DM) = {min(DM)}") - #print(f"min(GM) = {control.db2mag(min(GM))}") - #print(f"min(GM) = {min(GM)} dB") - #print(f"min(PM) = {min(PM)} deg\n\n") - - if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: - plt.show() - - sys.exit(0) + # Disk margin plot of admissible gain/phase variations for which + DM_plot = [] + DM_plot.append(control.disk_margins(L, omega, skew = -2.0)[0]) + DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) + DM_plot.append(control.disk_margins(L, omega, skew = 2.0)[0]) + plt.figure(10); plt.clf() + control.disk_margin_plot(DM_plot, skew = [-2.0, 0.0, 2.0]) + + return + +def test_siso2(): + # + # Disk-based stability margins for example + # SISO loop transfer function(s) + # + + # Frequencies of interest + omega = np.logspace(-1, 2, 1001) # Laplace variable s = control.tf('s') - # Disk-based stability margins for example SISO loop transfer function(s) - L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) - L = 6.25/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) - #print(f"L = {L}") + # Loop transfer gain + L = (6.25*(s + 3)*(s + 5))/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) + + print(f"------------- Python control built-in (S) -------------") + GM_, PM_, SM_ = control.stability_margins(L)[:3] # python-control default (S-based...?) + print(f"SM_ = {SM_}") + print(f"GM_ = {GM_} dB") + print(f"PM_ = {PM_} deg\n") + + print(f"------------- Sensitivity function (S) -------------") + DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n") + + plt.figure(2) + plt.subplot(3,3,1) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) + + plt.figure(2) + plt.subplot(3,3,4) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Gain Margin (dB)') + plt.legend() + plt.title('Gain-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) + + plt.figure(2) + plt.subplot(3,3,7) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Phase Margin (deg)') + plt.legend() + plt.title('Phase-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) + + print(f"------------- Complementary sensitivity function (T) -------------") + DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n") + + plt.figure(2) + plt.subplot(3,3,2) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) + + plt.figure(2) + plt.subplot(3,3,5) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Gain Margin (dB)') + plt.legend() + plt.title('Gain-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) + + plt.figure(2) + plt.subplot(3,3,8) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Phase Margin (deg)') + plt.legend() + plt.title('Phase-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) print(f"------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.margins.disk_margins(L, omega, 0.0) # balanced (S - T) - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {np.db2mag(min(GM))}") + DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n") + plt.figure(2) + plt.subplot(3,3,3) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) + + plt.figure(2) + plt.subplot(3,3,6) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Gain Margin (dB)') + plt.legend() + plt.title('Gain-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) + + plt.figure(2) + plt.subplot(3,3,9) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Phase Margin (deg)') + plt.legend() + plt.title('Phase-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) + + # Disk margin plot of admissible gain/phase variations for which + # the feedback loop still remains stable, for each skew parameter + DM_plot = [] + DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) + DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) + DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) + plt.figure(20) + control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) + + return + +def test_mimo(): + # + # Disk-based stability margins for example + # MIMO loop transfer function(s) + # + + # Frequencies of interest + omega = np.logspace(-1, 3, 1001) + + # Laplace variable + s = control.tf('s') + + # Loop transfer gain + P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant + K = control.ss([],[],[], [[1, -2], [0, 1]]) # controller + L = P*K # loop gain + print(f"------------- Sensitivity function (S) -------------") - DM, GM, PM = control.margins.disk_margins(L, omega, 1.0) # S-based (S) - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {np.db2mag(min(GM))}") + DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n") + plt.figure(3) + plt.subplot(3,3,1) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) + + plt.figure(3) + plt.subplot(3,3,4) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Gain Margin (dB)') + plt.legend() + plt.title('Gain-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) + + plt.figure(3) + plt.subplot(3,3,7) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Phase Margin (deg)') + plt.legend() + plt.title('Phase-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) + print(f"------------- Complementary sensitivity function (T) -------------") - DM, GM, PM = control.margins.disk_margins(L, omega, -1.0) # T-based (T) - print(f"min(DM) = {min(DM)}") - print(f"min(GM) = {np.db2mag(min(GM))}") + DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") print(f"min(GM) = {min(GM)} dB") print(f"min(PM) = {min(PM)} deg\n") - print(f"------------- Python control built-in -------------") - GM_, PM_, SM_ = stability_margins(L)[:3] # python-control default (S-based...?) - print(f"SM_ = {SM_}") - print(f"GM_ = {GM_} dB") - print(f"PM_ = {PM_} deg") + plt.figure(3) + plt.subplot(3,3,2) + plt.semilogx(omega, DM, label='$\\alpha$') + plt.legend() + plt.title('Disk Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) - plt.figure(1) - plt.subplot(2,3,1) + plt.figure(3) + plt.subplot(3,3,5) + plt.semilogx(omega, GM, label='$\\gamma_{m}$') + plt.ylabel('Gain Margin (dB)') + plt.legend() + plt.title('Gain-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) + + plt.figure(3) + plt.subplot(3,3,8) + plt.semilogx(omega, PM, label='$\\phi_{m}$') + plt.ylabel('Phase Margin (deg)') + plt.legend() + plt.title('Phase-Only Margin') + plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) + + print(f"------------- Balanced sensitivity function (S - T) -------------") + DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") + print(f"GM = {GM[np.argmin(DM)]} dB") + print(f"PM = {PM[np.argmin(DM)]} deg") + print(f"min(GM) = {min(GM)} dB") + print(f"min(PM) = {min(PM)} deg\n") + + plt.figure(3) + plt.subplot(3,3,3) plt.semilogx(omega, DM, label='$\\alpha$') plt.legend() plt.title('Disk Margin') plt.grid() + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 2]) - plt.figure(1) - plt.subplot(2,3,2) + plt.figure(3) + plt.subplot(3,3,6) plt.semilogx(omega, GM, label='$\\gamma_{m}$') - plt.ylabel('Margin (dB)') + plt.ylabel('Gain Margin (dB)') plt.legend() plt.title('Gain-Only Margin') plt.grid() - plt.ylim([0, 16]) + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 40]) - plt.figure(1) - plt.subplot(2,3,3) + plt.figure(3) + plt.subplot(3,3,9) plt.semilogx(omega, PM, label='$\\phi_{m}$') - plt.ylabel('Margin (deg)') + plt.ylabel('Phase Margin (deg)') plt.legend() plt.title('Phase-Only Margin') plt.grid() - plt.ylim([0, 180]) + plt.xlim([omega[0], omega[-1]]) + plt.ylim([0, 90]) + + # Disk margin plot of admissible gain/phase variations for which + # the feedback loop still remains stable, for each skew parameter + DM_plot = [] + DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) + DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) + DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) + plt.figure(30) + control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) + + return + +if __name__ == '__main__': + test_siso1() + test_siso2() + test_mimo() + + plt.show() + plt.tight_layout() + + + + From c3efe756180e6d863fb42c7b5a6fa09dbfe3a8c5 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 20 Apr 2025 23:07:53 -0400 Subject: [PATCH 08/34] Clean up docstring/code for disk_margin_plot --- control/margins.py | 46 ++++++++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/control/margins.py b/control/margins.py index dfd3552af..dd55f735f 100644 --- a/control/margins.py +++ b/control/margins.py @@ -704,24 +704,19 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): (not gmidx != -1 and float('inf')) or DGM[gmidx][0], (not DPM.shape[0] and float('inf')) or DPM[pmidx][0]) -def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, - shade = True, shade_alpha = 0.25): - """Compute disk-based stability margins for SISO or MIMO LTI system. +def disk_margin_plot(alpha_max, skew = 0.0, ax = None): + """Plot region of allowable gain/phase variation, given worst-case disk margin. Parameters ---------- - L : SISO or MIMO LTI system representing the loop transfer function - omega : ndarray - 1d array of (non-negative) frequencies (rad/s) at which to evaluate - the disk-based stability margins - skew : (optional, default = 0) skew parameter for disk margin calculation. + alpha_max : worst-case disk margin(s) across all (relevant) frequencies. + Note that skew may be a scalar or list. + skew : (optional, default = 0) skew parameter(s) for disk margin calculation. skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) skew = 1 uses the sensitivity function S skew = -1 uses the complementary sensitivity function T - returnall : bool, optional - If true, return all margins found. If False (default), return only the - minimum stability margins. Only margins in the given frequency region - can be found and returned. + Note that skew may be a scalar or list. + ax : axes to plot bounding curve(s) onto Returns ------- @@ -746,10 +741,13 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, >> >> s = control.tf('s') # Laplace variable >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop transfer function - >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0,) # balanced (S - T) >> + >> DM_plot = [] + >> DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) + >> DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) + >> DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) >> plt.figure(1) - >> disk_margin_plot(0.75, skew = [0.0, 1.0, -1.0]) + >> control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) >> plt.show() References @@ -775,11 +773,12 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, else: skew = np.asarray(skew) - - theta = np.linspace(0, np.pi, ntheta) + # Add a plot for each (alpha, skew) pair present + theta = np.linspace(0, np.pi, 500) legend_list = [] for ii in range(0, skew.shape[0]): - legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %(skew[ii], alpha_max[ii]) + legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %( + skew[ii], alpha_max[ii]) legend_list.append(legend_str) # Complex bounding curve of stable gain/phase variations @@ -791,15 +790,10 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None, ntheta = 500, phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) # Plot the allowable combined gain/phase variations - if shade: - out = ax.plot(gamma_dB, phi_deg, - alpha = shade_alpha, label = '_nolegend_') - ax.fill_between( - ax.lines[ii].get_xydata()[:,0], - ax.lines[ii].get_xydata()[:,1], - alpha = shade_alpha) - else: - out = ax.plot(gamma_dB, phi_deg) + out = ax.plot(gamma_dB, phi_deg, alpha = 0.25, + label = '_nolegend_') + ax.fill_between(ax.lines[ii].get_xydata()[:,0], + ax.lines[ii].get_xydata()[:,1], alpha = 0.25) plt.ylabel('Gain Variation (dB)') plt.xlabel('Phase Variation (deg)') From 63c8523303bb68745540a6e2c9f7344c44393ade Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 20 Apr 2025 23:08:21 -0400 Subject: [PATCH 09/34] Clean up docstring/code for disk_margin_plot --- control/margins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/margins.py b/control/margins.py index dd55f735f..cd4d4098f 100644 --- a/control/margins.py +++ b/control/margins.py @@ -755,7 +755,6 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None): [1] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, no. 5 (October 2020): 78-95. - """ # Create axis if needed From cffc3e505bb8af9e8d634fe4a977018bd9716d9a Mon Sep 17 00:00:00 2001 From: Josiah Date: Tue, 22 Apr 2025 20:56:44 -0400 Subject: [PATCH 10/34] Remove debugging statements, update comments, add unit tests. --- control/margins.py | 21 ++++--- control/tests/margin_test.py | 106 ++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/control/margins.py b/control/margins.py index cd4d4098f..b35de02a0 100644 --- a/control/margins.py +++ b/control/margins.py @@ -653,20 +653,23 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): ST_jw = ST_jw.transpose(2,0,1) # Frequency-dependent complex disk margin, computed using upper bound of - # the structured singular value, a.k.a. "mu", of (S + (skew - 1)/2). - # Uses SLICOT routine AB13MD to compute. [1,3-4]. - DM = np.zeros(omega.shape, np.float64) - DGM = np.zeros(omega.shape, np.float64) - DPM = np.zeros(omega.shape, np.float64) + # the structured singular value, a.k.a. "mu", of (S + (skew - I)/2). + DM = np.zeros(omega.shape, np.float64) # disk margin vs frequency + DGM = np.zeros(omega.shape, np.float64) # disk-based gain margin vs. frequency + DPM = np.zeros(omega.shape, np.float64) # disk-based phase margin vs. frequency for ii in range(0,len(omega)): # Disk margin (a.k.a. "alpha") vs. frequency if L.issiso() and (ab13md == None): - DM[ii] = np.minimum(1e5, - 1.0/bode(ST_jw, omega = omega[ii], plot = False)[0]) + # For the SISO case, the norm on (S + (skew - I)/2) is + # unstructured, and can be computed as Bode magnitude + DM[ii] = 1.0/bode(ST_jw, omega = omega[ii], plot = False)[0] else: - DM[ii] = np.minimum(1e5, - 1.0/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0]) + # For the MIMO case, the norm on (S + (skew - I)/2) assumes a + # single complex uncertainty block diagonal uncertainty structure. + # AB13MD provides an upper bound on this norm at the given frequency. + DM[ii] = 1.0/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] + # Disk-based gain margin (dB) and phase margin (deg) with np.errstate(divide = 'ignore', invalid = 'ignore'): # Real-axis intercepts with the disk gamma_min = (1 - 0.5*DM[ii]*(1 - skew))/(1 + 0.5*DM[ii]*(1 + skew)) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 43cd68ae3..16dfd1b55 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -14,7 +14,7 @@ from control import ControlMIMONotImplemented, FrequencyResponseData, \ StateSpace, TransferFunction, margin, phase_crossover_frequencies, \ - stability_margins + stability_margins, disk_margins, tf, ss s = TransferFunction.s @@ -372,3 +372,107 @@ def test_stability_margins_discrete(cnum, cden, dt, else: out = stability_margins(tf) assert_allclose(out, ref, rtol=rtol) + +def test_siso_disk_margin(): + # Frequencies of interest + omega = np.logspace(-1, 2, 1001) + + # Laplace variable + s = tf('s') + + # Loop transfer function + L = tf(25, [1, 10, 10, 10]) + + # Balanced (S - T) disk-based stability margins + DM, DGM, DPM = disk_margins(L, omega, skew = 0.0) + assert_allclose([DM], [0.46], atol = 0.1) # disk margin of 0.46 + assert_allclose([DGM], [4.05], atol = 0.1) # disk-based gain margin of 4.05 dB + assert_allclose([DPM], [25.8], atol = 0.1) # disk-based phase margin of 25.8 deg + + # For SISO systems, the S-based (S) disk margin should match the third output + # of existing library "stability_margins", i.e., minimum distance from the + # Nyquist plot to -1. + _, _, SM = stability_margins(L)[:3] + DM = disk_margins(L, omega, skew = 1.0)[0] + assert_allclose([DM], [SM], atol = 0.01) + +def test_mimo_disk_margin(): + # Frequencies of interest + omega = np.logspace(-1, 3, 1001) + + # Laplace variable + s = tf('s') + + # Loop transfer gain + P = ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant + K = ss([],[],[], [[1, -2], [0, 1]]) # controller + Lo = P*K # loop transfer function, broken at plant output + Li = K*P # loop transfer function, broken at plant input + + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0) + assert_allclose([DMo], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMo], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0) + assert_allclose([DMi], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMi], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + +def test_siso_disk_margin_return_all(): + # Frequencies of interest + omega = np.logspace(-1, 2, 1001) + + # Laplace variable + s = tf('s') + + # Loop transfer function + L = tf(25, [1, 10, 10, 10]) + + # Balanced (S - T) disk-based stability margins + DM, DGM, DPM = disk_margins(L, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DM)]], [1.94],\ + atol = 0.01) # sensitivity peak at 1.94 rad/s + assert_allclose([min(DM)], [0.46], atol = 0.1) # disk margin of 0.46 + assert_allclose([DGM[np.argmin(DM)]], [4.05],\ + atol = 0.1) # disk-based gain margin of 4.05 dB + assert_allclose([DPM[np.argmin(DM)]], [25.8],\ + atol = 0.1) # disk-based phase margin of 25.8 deg + +def test_mimo_disk_margin_return_all(): + # Frequencies of interest + omega = np.logspace(-1, 3, 1001) + + # Laplace variable + s = tf('s') + + # Loop transfer gain + P = ss([[0, 10],[-10, 0]], np.eye(2),\ + [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant + K = ss([],[],[], [[1, -2], [0, 1]]) # controller + Lo = P*K # loop transfer function, broken at plant output + Li = K*P # loop transfer function, broken at plant input + + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ + atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMo)], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ + atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ + atol = 0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ + atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMi)], [0.3754],\ + atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ + atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ + atol = 0.1) # disk-based phase margin of 21.26 deg + From 91517f9c7eea26327abd1db9a1ab7afdd2b10e95 Mon Sep 17 00:00:00 2001 From: Josiah Date: Wed, 23 Apr 2025 20:07:52 -0400 Subject: [PATCH 11/34] Minor change to fix logic to find minimum across DGM, DPM numpy vectors --- control/margins.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/control/margins.py b/control/margins.py index b35de02a0..caff19205 100644 --- a/control/margins.py +++ b/control/margins.py @@ -697,11 +697,12 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): # Worst-case disk margin, gain margin and phase margin if DGM.shape[0] and not np.isinf(DGM).all(): with np.errstate(all='ignore'): - gmidx = np.where(np.abs(DGM) == np.min(np.abs(DGM))) + gmidx = np.where(DGM == np.min(DGM)) else: gmidx = -1 + if DPM.shape[0]: - pmidx = np.where(np.abs(DPM) == np.amin(np.abs(DPM)))[0] + pmidx = np.where(DPM == np.min(DPM)) return ((not DM.shape[0] and float('inf')) or np.amin(DM), (not gmidx != -1 and float('inf')) or DGM[gmidx][0], From 86329e08024f357ee8b9c06ced5842b4a31c27a4 Mon Sep 17 00:00:00 2001 From: Josiah Date: Wed, 23 Apr 2025 20:51:43 -0400 Subject: [PATCH 12/34] Rename disk margin example, since unit tests are now written in control/tests/margin_test.py --- examples/{test_margins.py => disk_margins.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/{test_margins.py => disk_margins.py} (100%) diff --git a/examples/test_margins.py b/examples/disk_margins.py similarity index 100% rename from examples/test_margins.py rename to examples/disk_margins.py From d92fb2045a786581741ddb703819f7ae5865a323 Mon Sep 17 00:00:00 2001 From: Josiah Date: Thu, 24 Apr 2025 06:39:41 -0400 Subject: [PATCH 13/34] Remove unneeded dependencies from margins.py, used for debugging --- control/margins.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/control/margins.py b/control/margins.py index caff19205..cf5e40acb 100644 --- a/control/margins.py +++ b/control/margins.py @@ -6,8 +6,6 @@ """Functions for computing stability margins and related functions.""" import math -import matplotlib as mpl -import matplotlib.pyplot as plt from warnings import warn import numpy as np From b2a2edc620f26fed6e897f0269618899c3b2562b Mon Sep 17 00:00:00 2001 From: Josiah Date: Thu, 24 Apr 2025 06:42:54 -0400 Subject: [PATCH 14/34] Minor updates to docstrings --- control/margins.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/control/margins.py b/control/margins.py index cf5e40acb..0701778f8 100644 --- a/control/margins.py +++ b/control/margins.py @@ -536,11 +536,13 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): Parameters ---------- - L : SISO or MIMO LTI system representing the loop transfer function + L : SISO or MIMO LTI system + Loop transfer function, e.g. P*C or C*P omega : ndarray 1d array of (non-negative) frequencies (rad/s) at which to evaluate the disk-based stability margins - skew : (optional, default = 0) skew parameter for disk margin calculation. + skew : float, optional, default = 0 + skew parameter for disk margin calculation. skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) skew = 1 uses the sensitivity function S skew = -1 uses the complementary sensitivity function T @@ -711,9 +713,11 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None): Parameters ---------- - alpha_max : worst-case disk margin(s) across all (relevant) frequencies. + alpha_max : float + worst-case disk margin(s) across all (relevant) frequencies. Note that skew may be a scalar or list. - skew : (optional, default = 0) skew parameter(s) for disk margin calculation. + skew : float, optional, default = 0 + skew parameter(s) for disk margin calculation. skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) skew = 1 uses the sensitivity function S skew = -1 uses the complementary sensitivity function T From 1f0ee52af1c41a2d486ad91ac5e91211cde2a3aa Mon Sep 17 00:00:00 2001 From: Josiah Date: Thu, 24 Apr 2025 06:56:28 -0400 Subject: [PATCH 15/34] Undo d92fb2045a786581741ddb703819f7ae5865a323 --- control/margins.py | 2 ++ examples/disk_margins.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/control/margins.py b/control/margins.py index 0701778f8..ed0f9ce06 100644 --- a/control/margins.py +++ b/control/margins.py @@ -10,6 +10,8 @@ import numpy as np import scipy as sp +import matplotlib +import matplotlib.pyplot as plt from . import frdata, freqplot, xferfcn from .exception import ControlMIMONotImplemented diff --git a/examples/disk_margins.py b/examples/disk_margins.py index a6725a032..8d37cccf1 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -4,7 +4,6 @@ import os, sys, math import numpy as np -import matplotlib.pyplot as plt import control import math From ba41e8c585b11309d0844b685b1676dbe760fd4c Mon Sep 17 00:00:00 2001 From: Josiah Date: Thu, 24 Apr 2025 07:15:37 -0400 Subject: [PATCH 16/34] Minor tweaks to plots in example script for readability --- examples/disk_margins.py | 80 ++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/examples/disk_margins.py b/examples/disk_margins.py index 8d37cccf1..35f6e9715 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -46,8 +46,9 @@ def test_siso1(): plt.figure(1) plt.subplot(3,3,1) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('S-Based Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -57,7 +58,7 @@ def test_siso1(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -67,10 +68,11 @@ def test_siso1(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') print(f"------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) @@ -83,8 +85,9 @@ def test_siso1(): plt.figure(1) plt.subplot(3,3,2) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('T_Based Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -94,7 +97,7 @@ def test_siso1(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -104,10 +107,11 @@ def test_siso1(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') print(f"------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) @@ -120,8 +124,9 @@ def test_siso1(): plt.figure(1) plt.subplot(3,3,3) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('Balanced Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -131,7 +136,7 @@ def test_siso1(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -141,10 +146,11 @@ def test_siso1(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') # Disk margin plot of admissible gain/phase variations for which DM_plot = [] @@ -188,8 +194,9 @@ def test_siso2(): plt.figure(2) plt.subplot(3,3,1) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('S-Based Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -199,7 +206,7 @@ def test_siso2(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -209,10 +216,11 @@ def test_siso2(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') print(f"------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) @@ -225,8 +233,9 @@ def test_siso2(): plt.figure(2) plt.subplot(3,3,2) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('T-Based Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -236,7 +245,7 @@ def test_siso2(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -246,10 +255,11 @@ def test_siso2(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') print(f"------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) @@ -262,8 +272,9 @@ def test_siso2(): plt.figure(2) plt.subplot(3,3,3) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('Balanced Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -273,7 +284,7 @@ def test_siso2(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -283,10 +294,11 @@ def test_siso2(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') # Disk margin plot of admissible gain/phase variations for which # the feedback loop still remains stable, for each skew parameter @@ -327,8 +339,9 @@ def test_mimo(): plt.figure(3) plt.subplot(3,3,1) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('S-Based Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -338,7 +351,7 @@ def test_mimo(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -348,10 +361,11 @@ def test_mimo(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') print(f"------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) @@ -364,8 +378,9 @@ def test_mimo(): plt.figure(3) plt.subplot(3,3,2) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('T-Based Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -375,7 +390,7 @@ def test_mimo(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -385,10 +400,11 @@ def test_mimo(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') print(f"------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) @@ -401,8 +417,9 @@ def test_mimo(): plt.figure(3) plt.subplot(3,3,3) plt.semilogx(omega, DM, label='$\\alpha$') + plt.ylabel('Disk Margin (abs)') plt.legend() - plt.title('Disk Margin') + plt.title('Balanced Margins') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 2]) @@ -412,7 +429,7 @@ def test_mimo(): plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() - plt.title('Gain-Only Margin') + #plt.title('Gain-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 40]) @@ -422,10 +439,11 @@ def test_mimo(): plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() - plt.title('Phase-Only Margin') + #plt.title('Phase-Only Margin') plt.grid() plt.xlim([omega[0], omega[-1]]) plt.ylim([0, 90]) + plt.xlabel('Frequency (rad/s)') # Disk margin plot of admissible gain/phase variations for which # the feedback loop still remains stable, for each skew parameter @@ -439,13 +457,13 @@ def test_mimo(): return if __name__ == '__main__': - test_siso1() - test_siso2() + #test_siso1() + #test_siso2() test_mimo() + #plt.tight_layout() plt.show() - plt.tight_layout() - + From 14eb315b69fb7e1257994a468c512879e367094e Mon Sep 17 00:00:00 2001 From: Josiah Date: Thu, 24 Apr 2025 07:23:26 -0400 Subject: [PATCH 17/34] Fix typo in disk_margin_plot. --- control/margins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control/margins.py b/control/margins.py index ed0f9ce06..59b705589 100644 --- a/control/margins.py +++ b/control/margins.py @@ -802,8 +802,8 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None): ax.fill_between(ax.lines[ii].get_xydata()[:,0], ax.lines[ii].get_xydata()[:,1], alpha = 0.25) - plt.ylabel('Gain Variation (dB)') - plt.xlabel('Phase Variation (deg)') + plt.ylabel('Phase Variation (deg)') + plt.xlabel('Gain Variation (dB)') plt.title('Range of Gain and Phase Variations') plt.legend(legend_list) plt.grid() From 0bebc1de9b2ebe28cd26d70ebcefd485bc057fea Mon Sep 17 00:00:00 2001 From: Josiah Date: Fri, 25 Apr 2025 06:57:03 -0400 Subject: [PATCH 18/34] Fix mag2db import hack/workaround and trim down disk_margin docstring. --- control/margins.py | 61 ++++++++-------------------------------------- 1 file changed, 10 insertions(+), 51 deletions(-) diff --git a/control/margins.py b/control/margins.py index 59b705589..b664df26c 100644 --- a/control/margins.py +++ b/control/margins.py @@ -17,20 +17,11 @@ from .exception import ControlMIMONotImplemented from .iosys import issiso from . import ss +from .ctrlutil import mag2db try: from slycot import ab13md except ImportError: ab13md = None -try: - from . import mag2db -except ImportError: - # Likely due the following circular import issue: - # - # ImportError: cannot import name 'mag2db' from partially initialized module - # 'control' (most likely due to a circular import) (control/__init__.py) - # - def mag2db(mag): - return 20*np.log10(mag) __all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin', 'disk_margins', 'disk_margin_plot'] @@ -567,52 +558,20 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): Examples -------- - >> import control - >> import numpy as np - >> import matplotlib - >> import matplotlib.pyplot as plt - >> - >> omega = np.logspace(-1, 3, 1001) + >> omega = np.logspace(-1, 3, 1001) # frequencies of interest (rad/s) + >> P = control.ss([[0,10],[-10,0]],np.eye(2),[[1,10],[-10,1]],[[0,0],[0,0]]) # plant + >> K = control.ss([],[],[],[[1,-2],[0,1]]) # controller + >> L = P*K # output loop gain >> - >> P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) - >> K = control.ss([],[],[], [[1, -2], [0, 1]]) - >> L = P*K + >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = False) + >> print(f"DM = {DM}") + >> print(f"GM = {GM} dB") + >> print(f"PM = {PM} deg\n") >> - >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) >> print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") >> print(f"GM = {GM[np.argmin(DM)]} dB") >> print(f"PM = {PM[np.argmin(DM)]} deg\n") - >> - >> plt.figure(1) - >> plt.subplot(3,1,1) - >> plt.semilogx(omega, DM, label='$\\alpha$') - >> plt.legend() - >> plt.title('Disk Margin') - >> plt.grid() - >> plt.tight_layout() - >> plt.xlim([omega[0], omega[-1]]) - >> - >> plt.figure(1) - >> plt.subplot(3,1,2) - >> plt.semilogx(omega, GM, label='$\\gamma_{m}$') - >> plt.ylabel('Gain Margin (dB)') - >> plt.legend() - >> plt.title('Disk-Based Gain Margin') - >> plt.grid() - >> plt.ylim([0, 40]) - >> plt.tight_layout() - >> plt.xlim([omega[0], omega[-1]]) - >> - >> plt.figure(1) - >> plt.subplot(3,1,3) - >> plt.semilogx(omega, PM, label='$\\phi_{m}$') - >> plt.ylabel('Phase Margin (deg)') - >> plt.legend() - >> plt.title('Disk-Based Phase Margin') - >> plt.grid() - >> plt.ylim([0, 90]) - >> plt.tight_layout() - >> plt.xlim([omega[0], omega[-1]]) References ---------- From 87714bdaa3c7b23fa34990649242d602b51b0d0f Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 09:48:27 -0400 Subject: [PATCH 19/34] Add input handling to disk_margin, clean up column width/comments --- control/margins.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/control/margins.py b/control/margins.py index b664df26c..7504c1285 100644 --- a/control/margins.py +++ b/control/margins.py @@ -13,17 +13,17 @@ import matplotlib import matplotlib.pyplot as plt -from . import frdata, freqplot, xferfcn +from . import frdata, freqplot, xferfcn, statesp from .exception import ControlMIMONotImplemented from .iosys import issiso -from . import ss from .ctrlutil import mag2db try: from slycot import ab13md except ImportError: ab13md = None -__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin', 'disk_margins', 'disk_margin_plot'] +__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin',\ + 'disk_margins', 'disk_margin_plot'] # private helper functions def _poly_iw(sys): @@ -525,12 +525,12 @@ def margin(*args): return margin[0], margin[1], margin[3], margin[4] def disk_margins(L, omega, skew = 0.0, returnall = False): - """Compute disk-based stability margins for SISO or MIMO LTI system. + """Compute disk-based stability margins for SISO or MIMO LTI loop transfer function. Parameters ---------- L : SISO or MIMO LTI system - Loop transfer function, e.g. P*C or C*P + Loop transfer function, i.e., P*C or C*P omega : ndarray 1d array of (non-negative) frequencies (rad/s) at which to evaluate the disk-based stability margins @@ -594,13 +594,21 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): Control Systems Magazine, Vol. 24, Nr. 1, Feb., pp. 60-76, 2004. """ - # Check for prerequisites + # First argument must be a system + if not isinstance(L, (statesp.StateSpace, xferfcn.TransferFunction)): + raise ValueError("Loop gain must be state-space or transfer function object") + + # Loop transfer function must be square + if statesp.ss(L).B.shape[1] != statesp.ss(L).C.shape[0]: + raise ValueError("Loop gain must be square (n_inputs = n_outputs)") + + # Need slycot if L is MIMO, for mu calculation if (not L.issiso()) and (ab13md == None): raise ControlMIMONotImplemented("Need slycot to compute MIMO disk_margins") # Get dimensions of feedback system - ny,_ = ss(L).C.shape - I = ss([], [], [], np.eye(ny)) + num_loops = statesp.ss(L).C.shape[0] + I = statesp.ss([], [], [], np.eye(num_loops)) # Loop sensitivity function S = I.feedback(L) @@ -628,7 +636,8 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): # For the MIMO case, the norm on (S + (skew - I)/2) assumes a # single complex uncertainty block diagonal uncertainty structure. # AB13MD provides an upper bound on this norm at the given frequency. - DM[ii] = 1.0/ab13md(ST_jw[ii], np.array(ny*[1]), np.array(ny*[2]))[0] + DM[ii] = 1.0/ab13md(ST_jw[ii], np.array(num_loops*[1]),\ + np.array(num_loops*[2]))[0] # Disk-based gain margin (dB) and phase margin (deg) with np.errstate(divide = 'ignore', invalid = 'ignore'): @@ -669,20 +678,18 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): (not gmidx != -1 and float('inf')) or DGM[gmidx][0], (not DPM.shape[0] and float('inf')) or DPM[pmidx][0]) -def disk_margin_plot(alpha_max, skew = 0.0, ax = None): +def disk_margin_plot(alpha_max, skew, ax = None): """Plot region of allowable gain/phase variation, given worst-case disk margin. Parameters ---------- - alpha_max : float - worst-case disk margin(s) across all (relevant) frequencies. - Note that skew may be a scalar or list. - skew : float, optional, default = 0 + alpha_max : float (scalar or list) + worst-case disk margin(s) across all frequencies. May be a scalar or list. + skew : float (scalar or list) skew parameter(s) for disk margin calculation. skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) skew = 1 uses the sensitivity function S skew = -1 uses the complementary sensitivity function T - Note that skew may be a scalar or list. ax : axes to plot bounding curve(s) onto Returns @@ -707,7 +714,7 @@ def disk_margin_plot(alpha_max, skew = 0.0, ax = None): >> omega = np.logspace(-1, 2, 1001) >> >> s = control.tf('s') # Laplace variable - >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop transfer function + >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop gain >> >> DM_plot = [] >> DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) From c17910ff1e27b2db56661f70592c962680f538e1 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 10:22:49 -0400 Subject: [PATCH 20/34] Move disk_margin_plot out of the library into the example script --- control/margins.py | 101 +---------------------------------- examples/disk_margins.py | 111 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 106 deletions(-) diff --git a/control/margins.py b/control/margins.py index 7504c1285..511db1e9a 100644 --- a/control/margins.py +++ b/control/margins.py @@ -23,7 +23,7 @@ ab13md = None __all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin',\ - 'disk_margins', 'disk_margin_plot'] + 'disk_margins'] # private helper functions def _poly_iw(sys): @@ -677,102 +677,3 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): return ((not DM.shape[0] and float('inf')) or np.amin(DM), (not gmidx != -1 and float('inf')) or DGM[gmidx][0], (not DPM.shape[0] and float('inf')) or DPM[pmidx][0]) - -def disk_margin_plot(alpha_max, skew, ax = None): - """Plot region of allowable gain/phase variation, given worst-case disk margin. - - Parameters - ---------- - alpha_max : float (scalar or list) - worst-case disk margin(s) across all frequencies. May be a scalar or list. - skew : float (scalar or list) - skew parameter(s) for disk margin calculation. - skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) - skew = 1 uses the sensitivity function S - skew = -1 uses the complementary sensitivity function T - ax : axes to plot bounding curve(s) onto - - Returns - ------- - DM : ndarray - 1D array of frequency-dependent disk margins. DM is the same - size as "omega" parameter. - GM : ndarray - 1D array of frequency-dependent disk-based gain margins, in dB. - GM is the same size as "omega" parameter. - PM : ndarray - 1D array of frequency-dependent disk-based phase margins, in deg. - PM is the same size as "omega" parameter. - - Examples - -------- - >> import control - >> import numpy as np - >> import matplotlib - >> import matplotlib.pyplot as plt - >> - >> omega = np.logspace(-1, 2, 1001) - >> - >> s = control.tf('s') # Laplace variable - >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop gain - >> - >> DM_plot = [] - >> DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) - >> DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) - >> DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) - >> plt.figure(1) - >> control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) - >> plt.show() - - References - ---------- - [1] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction - to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, - no. 5 (October 2020): 78-95. - """ - - # Create axis if needed - if ax is None: - ax = plt.gca() - - # Allow scalar or vector arguments (to overlay plots) - if np.isscalar(alpha_max): - alpha_max = np.asarray([alpha_max]) - else: - alpha_max = np.asarray(alpha_max) - - if np.isscalar(skew): - skew = np.asarray([skew]) - else: - skew = np.asarray(skew) - - # Add a plot for each (alpha, skew) pair present - theta = np.linspace(0, np.pi, 500) - legend_list = [] - for ii in range(0, skew.shape[0]): - legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %( - skew[ii], alpha_max[ii]) - legend_list.append(legend_str) - - # Complex bounding curve of stable gain/phase variations - f = (2 + alpha_max[ii]*(1 - skew[ii])*np.exp(1j*theta))/\ - (2 - alpha_max[ii]*(1 + skew[ii])*np.exp(1j*theta)) - - # Allowable combined gain/phase variations - gamma_dB = mag2db(np.abs(f)) # gain margin (dB) - phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) - - # Plot the allowable combined gain/phase variations - out = ax.plot(gamma_dB, phi_deg, alpha = 0.25, - label = '_nolegend_') - ax.fill_between(ax.lines[ii].get_xydata()[:,0], - ax.lines[ii].get_xydata()[:,1], alpha = 0.25) - - plt.ylabel('Phase Variation (deg)') - plt.xlabel('Gain Variation (dB)') - plt.title('Range of Gain and Phase Variations') - plt.legend(legend_list) - plt.grid() - plt.tight_layout() - - return out diff --git a/examples/disk_margins.py b/examples/disk_margins.py index 35f6e9715..8489a307d 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -14,6 +14,105 @@ import numpy as np import scipy as sp +def plot_allowable_region(alpha_max, skew, ax = None): + """Plot region of allowable gain/phase variation, given worst-case disk margin. + + Parameters + ---------- + alpha_max : float (scalar or list) + worst-case disk margin(s) across all frequencies. May be a scalar or list. + skew : float (scalar or list) + skew parameter(s) for disk margin calculation. + skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) + skew = 1 uses the sensitivity function S + skew = -1 uses the complementary sensitivity function T + ax : axes to plot bounding curve(s) onto + + Returns + ------- + DM : ndarray + 1D array of frequency-dependent disk margins. DM is the same + size as "omega" parameter. + GM : ndarray + 1D array of frequency-dependent disk-based gain margins, in dB. + GM is the same size as "omega" parameter. + PM : ndarray + 1D array of frequency-dependent disk-based phase margins, in deg. + PM is the same size as "omega" parameter. + + Examples + -------- + >> import control + >> import numpy as np + >> import matplotlib + >> import matplotlib.pyplot as plt + >> + >> omega = np.logspace(-1, 2, 1001) + >> + >> s = control.tf('s') # Laplace variable + >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop gain + >> + >> DM_plot = [] + >> DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) + >> DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) + >> DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) + >> plt.figure(1) + >> control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) + >> plt.show() + + References + ---------- + [1] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction + to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, + no. 5 (October 2020): 78-95. + """ + + # Create axis if needed + if ax is None: + ax = plt.gca() + + # Allow scalar or vector arguments (to overlay plots) + if np.isscalar(alpha_max): + alpha_max = np.asarray([alpha_max]) + else: + alpha_max = np.asarray(alpha_max) + + if np.isscalar(skew): + skew = np.asarray([skew]) + else: + skew = np.asarray(skew) + + # Add a plot for each (alpha, skew) pair present + theta = np.linspace(0, np.pi, 500) + legend_list = [] + for ii in range(0, skew.shape[0]): + legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %( + skew[ii], alpha_max[ii]) + legend_list.append(legend_str) + + # Complex bounding curve of stable gain/phase variations + f = (2 + alpha_max[ii]*(1 - skew[ii])*np.exp(1j*theta))/\ + (2 - alpha_max[ii]*(1 + skew[ii])*np.exp(1j*theta)) + + # Allowable combined gain/phase variations + gamma_dB = control.ctrlutil.mag2db(np.abs(f)) # gain margin (dB) + phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) + + # Plot the allowable combined gain/phase variations + out = ax.plot(gamma_dB, phi_deg, alpha = 0.25, + label = '_nolegend_') + ax.fill_between(ax.lines[ii].get_xydata()[:,0], + ax.lines[ii].get_xydata()[:,1], alpha = 0.25) + + plt.ylabel('Phase Variation (deg)') + plt.xlabel('Gain Variation (dB)') + plt.title('Range of Gain and Phase Variations') + plt.legend(legend_list) + plt.grid() + plt.tight_layout() + + return out + def test_siso1(): # # Disk-based stability margins for example @@ -158,7 +257,7 @@ def test_siso1(): DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) DM_plot.append(control.disk_margins(L, omega, skew = 2.0)[0]) plt.figure(10); plt.clf() - control.disk_margin_plot(DM_plot, skew = [-2.0, 0.0, 2.0]) + plot_allowable_region(DM_plot, skew = [-2.0, 0.0, 2.0]) return @@ -307,7 +406,7 @@ def test_siso2(): DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) plt.figure(20) - control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) + plot_allowable_region(DM_plot, skew = [-1.0, 0.0, 1.0]) return @@ -452,7 +551,7 @@ def test_mimo(): DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) plt.figure(30) - control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) + plot_allowable_region(DM_plot, skew = [-1.0, 0.0, 1.0]) return @@ -460,9 +559,9 @@ def test_mimo(): #test_siso1() #test_siso2() test_mimo() - - #plt.tight_layout() - plt.show() + if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + #plt.tight_layout() + plt.show() From 5f34a7bea410715ee1389a36cd8de3e7001ebf34 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 10:32:25 -0400 Subject: [PATCH 21/34] Recommended changes from the linter --- control/margins.py | 4 +--- control/tests/margin_test.py | 6 ------ examples/disk_margins.py | 39 +++++++++++++----------------------- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/control/margins.py b/control/margins.py index 511db1e9a..63f0bf1ef 100644 --- a/control/margins.py +++ b/control/margins.py @@ -10,8 +10,6 @@ import numpy as np import scipy as sp -import matplotlib -import matplotlib.pyplot as plt from . import frdata, freqplot, xferfcn, statesp from .exception import ControlMIMONotImplemented @@ -631,7 +629,7 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): if L.issiso() and (ab13md == None): # For the SISO case, the norm on (S + (skew - I)/2) is # unstructured, and can be computed as Bode magnitude - DM[ii] = 1.0/bode(ST_jw, omega = omega[ii], plot = False)[0] + DM[ii] = 1.0/ST_mag[ii] else: # For the MIMO case, the norm on (S + (skew - I)/2) assumes a # single complex uncertainty block diagonal uncertainty structure. diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 16dfd1b55..eca43883e 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -425,9 +425,6 @@ def test_siso_disk_margin_return_all(): # Frequencies of interest omega = np.logspace(-1, 2, 1001) - # Laplace variable - s = tf('s') - # Loop transfer function L = tf(25, [1, 10, 10, 10]) @@ -445,9 +442,6 @@ def test_mimo_disk_margin_return_all(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) - # Laplace variable - s = tf('s') - # Loop transfer gain P = ss([[0, 10],[-10, 0]], np.eye(2),\ [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant diff --git a/examples/disk_margins.py b/examples/disk_margins.py index 8489a307d..8e004c1d3 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -2,17 +2,12 @@ Demonstrate disk-based stability margin calculations. """ -import os, sys, math -import numpy as np -import control - +import os import math -import matplotlib as mpl +import control +import matplotlib import matplotlib.pyplot as plt -from warnings import warn - import numpy as np -import scipy as sp def plot_allowable_region(alpha_max, skew, ax = None): """Plot region of allowable gain/phase variation, given worst-case disk margin. @@ -122,19 +117,16 @@ def test_siso1(): # Frequencies of interest omega = np.logspace(-1, 2, 1001) - # Laplace variable - s = control.tf('s') - # Loop transfer gain L = control.tf(25, [1, 10, 10, 10]) - print(f"------------- Python control built-in (S) -------------") + print("------------- Python control built-in (S) -------------") GM_, PM_, SM_ = control.stability_margins(L)[:3] # python-control default (S-based...?) print(f"SM_ = {SM_}") print(f"GM_ = {GM_} dB") print(f"PM_ = {PM_} deg\n") - print(f"------------- Sensitivity function (S) -------------") + print("------------- Sensitivity function (S) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -173,7 +165,7 @@ def test_siso1(): plt.ylim([0, 90]) plt.xlabel('Frequency (rad/s)') - print(f"------------- Complementary sensitivity function (T) -------------") + print("------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -212,7 +204,7 @@ def test_siso1(): plt.ylim([0, 90]) plt.xlabel('Frequency (rad/s)') - print(f"------------- Balanced sensitivity function (S - T) -------------") + print("------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -276,13 +268,13 @@ def test_siso2(): # Loop transfer gain L = (6.25*(s + 3)*(s + 5))/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) - print(f"------------- Python control built-in (S) -------------") + print("------------- Python control built-in (S) -------------") GM_, PM_, SM_ = control.stability_margins(L)[:3] # python-control default (S-based...?) print(f"SM_ = {SM_}") print(f"GM_ = {GM_} dB") print(f"PM_ = {PM_} deg\n") - print(f"------------- Sensitivity function (S) -------------") + print("------------- Sensitivity function (S) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -321,7 +313,7 @@ def test_siso2(): plt.ylim([0, 90]) plt.xlabel('Frequency (rad/s)') - print(f"------------- Complementary sensitivity function (T) -------------") + print("------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -360,7 +352,7 @@ def test_siso2(): plt.ylim([0, 90]) plt.xlabel('Frequency (rad/s)') - print(f"------------- Balanced sensitivity function (S - T) -------------") + print("------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -419,15 +411,12 @@ def test_mimo(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) - # Laplace variable - s = control.tf('s') - # Loop transfer gain P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant K = control.ss([],[],[], [[1, -2], [0, 1]]) # controller L = P*K # loop gain - print(f"------------- Sensitivity function (S) -------------") + print("------------- Sensitivity function (S) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -466,7 +455,7 @@ def test_mimo(): plt.ylim([0, 90]) plt.xlabel('Frequency (rad/s)') - print(f"------------- Complementary sensitivity function (T) -------------") + print("------------- Complementary sensitivity function (T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") @@ -505,7 +494,7 @@ def test_mimo(): plt.ylim([0, 90]) plt.xlabel('Frequency (rad/s)') - print(f"------------- Balanced sensitivity function (S - T) -------------") + print("------------- Balanced sensitivity function (S - T) -------------") DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") From f0e2d746a350f0a10c2a8883236f96a6bc6a373f Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 10:34:04 -0400 Subject: [PATCH 22/34] Follow-on to 5f34a7bea410715ee1389a36cd8de3e7001ebf34 --- control/tests/margin_test.py | 6 ------ examples/disk_margins.py | 2 -- 2 files changed, 8 deletions(-) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index eca43883e..411a61aec 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -377,9 +377,6 @@ def test_siso_disk_margin(): # Frequencies of interest omega = np.logspace(-1, 2, 1001) - # Laplace variable - s = tf('s') - # Loop transfer function L = tf(25, [1, 10, 10, 10]) @@ -400,9 +397,6 @@ def test_mimo_disk_margin(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) - # Laplace variable - s = tf('s') - # Loop transfer gain P = ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant K = ss([],[],[], [[1, -2], [0, 1]]) # controller diff --git a/examples/disk_margins.py b/examples/disk_margins.py index 8e004c1d3..44787d3c4 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -3,9 +3,7 @@ """ import os -import math import control -import matplotlib import matplotlib.pyplot as plt import numpy as np From a5fcb91606c4f2e5223997f57d70a1aaff6dbd10 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 11:09:44 -0400 Subject: [PATCH 23/34] Add disk_margins to function list --- doc/functions.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/functions.rst b/doc/functions.rst index d657fd431..3d3614a9b 100644 --- a/doc/functions.rst +++ b/doc/functions.rst @@ -150,6 +150,7 @@ Frequency domain analysis: singular_values_plot singular_values_response sisotool + disk_margins Pole/zero-based analysis: From 077d538df502ee269b8e23fe172f392509c5c22c Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 11:10:30 -0400 Subject: [PATCH 24/34] Whittle down the docstring from disk_margins --- control/margins.py | 101 +++++++++++++++------------------------------ 1 file changed, 33 insertions(+), 68 deletions(-) diff --git a/control/margins.py b/control/margins.py index 63f0bf1ef..32835be7e 100644 --- a/control/margins.py +++ b/control/margins.py @@ -523,78 +523,40 @@ def margin(*args): return margin[0], margin[1], margin[3], margin[4] def disk_margins(L, omega, skew = 0.0, returnall = False): - """Compute disk-based stability margins for SISO or MIMO LTI loop transfer function. + """Compute disk-based stability margins for SISO or MIMO LTI + loop transfer function. Parameters ---------- - L : SISO or MIMO LTI system - Loop transfer function, i.e., P*C or C*P - omega : ndarray - 1d array of (non-negative) frequencies (rad/s) at which to evaluate - the disk-based stability margins - skew : float, optional, default = 0 - skew parameter for disk margin calculation. - skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) - skew = 1 uses the sensitivity function S - skew = -1 uses the complementary sensitivity function T - returnall : bool, optional - If true, return all margins found. If False (default), return only the - minimum stability margins. Only margins in the given frequency region - can be found and returned. + L : `StateSpace` or `TransferFunction` + Linear SISO or MIMO loop transfer function system + omega : sequence of array_like + 1D array of (non-negative) frequencies (rad/s) at which + to evaluate the disk-based stability margins Returns ------- - DM : ndarray - 1D array of frequency-dependent disk margins. DM is the same - size as "omega" parameter. - GM : ndarray - 1D array of frequency-dependent disk-based gain margins, in dB. - GM is the same size as "omega" parameter. - PM : ndarray - 1D array of frequency-dependent disk-based phase margins, in deg. - PM is the same size as "omega" parameter. - - Examples + DM : float or array_like + Disk margin. + DGM : float or array_like + Disk-based gain margin. + DPM : float or array_like + Disk-based phase margin. + + Example -------- - >> omega = np.logspace(-1, 3, 1001) # frequencies of interest (rad/s) - >> P = control.ss([[0,10],[-10,0]],np.eye(2),[[1,10],[-10,1]],[[0,0],[0,0]]) # plant - >> K = control.ss([],[],[],[[1,-2],[0,1]]) # controller - >> L = P*K # output loop gain - >> - >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = False) - >> print(f"DM = {DM}") - >> print(f"GM = {GM} dB") - >> print(f"PM = {PM} deg\n") - >> - >> DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) - >> print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") - >> print(f"GM = {GM[np.argmin(DM)]} dB") - >> print(f"PM = {PM[np.argmin(DM)]} deg\n") - - References - ---------- - [1] Blight, James D., R. Lane Dailey, and Dagfinn Gangsaas. “Practical - Control Law Design for Aircraft Using Multivariable Techniques.” - International Journal of Control 59, no. 1 (January 1994): 93-137. - https://doi.org/10.1080/00207179408923071. - - [2] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction - to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, - no. 5 (October 2020): 78-95. - - [3] P. Benner, V. Mehrmann, V. Sima, S. Van Huffel, and A. Varga, "SLICOT - - A Subroutine Library in Systems and Control Theory", Applied and - Computational Control, Signals, and Circuits (Birkhauser), Vol. 1, Ch. - 10, pp. 505-546, 1999. - - [4] S. Van Huffel, V. Sima, A. Varga, S. Hammarling, and F. Delebecque, - "Development of High Performance Numerical Software for Control", IEEE - Control Systems Magazine, Vol. 24, Nr. 1, Feb., pp. 60-76, 2004. + >> omega = np.logspace(-1, 3, 1001) + >> P = control.ss([[0,10],[-10,0]],np.eye(2),[[1,10],\ + [-10,1]],[[0,0],[0,0]]) + >> K = control.ss([],[],[],[[1,-2],[0,1]]) + >> L = P*K + >> DM, DGM, DPM = control.disk_margins(L, omega, skew = 0.0) """ # First argument must be a system if not isinstance(L, (statesp.StateSpace, xferfcn.TransferFunction)): - raise ValueError("Loop gain must be state-space or transfer function object") + raise ValueError(\ + "Loop gain must be state-space or transfer function object") # Loop transfer function must be square if statesp.ss(L).B.shape[1] != statesp.ss(L).C.shape[0]: @@ -602,7 +564,8 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): # Need slycot if L is MIMO, for mu calculation if (not L.issiso()) and (ab13md == None): - raise ControlMIMONotImplemented("Need slycot to compute MIMO disk_margins") + raise ControlMIMONotImplemented(\ + "Need slycot to compute MIMO disk_margins") # Get dimensions of feedback system num_loops = statesp.ss(L).C.shape[0] @@ -621,19 +584,21 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): # Frequency-dependent complex disk margin, computed using upper bound of # the structured singular value, a.k.a. "mu", of (S + (skew - I)/2). - DM = np.zeros(omega.shape, np.float64) # disk margin vs frequency - DGM = np.zeros(omega.shape, np.float64) # disk-based gain margin vs. frequency - DPM = np.zeros(omega.shape, np.float64) # disk-based phase margin vs. frequency + DM = np.zeros(omega.shape, np.float64) + DGM = np.zeros(omega.shape, np.float64) + DPM = np.zeros(omega.shape, np.float64) for ii in range(0,len(omega)): # Disk margin (a.k.a. "alpha") vs. frequency if L.issiso() and (ab13md == None): # For the SISO case, the norm on (S + (skew - I)/2) is - # unstructured, and can be computed as Bode magnitude + # unstructured, and can be computed as the magnitude + # of the frequency response. DM[ii] = 1.0/ST_mag[ii] else: # For the MIMO case, the norm on (S + (skew - I)/2) assumes a - # single complex uncertainty block diagonal uncertainty structure. - # AB13MD provides an upper bound on this norm at the given frequency. + # single complex uncertainty block diagonal uncertainty + # structure. AB13MD provides an upper bound on this norm at + # the given frequency omega[ii]. DM[ii] = 1.0/ab13md(ST_jw[ii], np.array(num_loops*[1]),\ np.array(num_loops*[2]))[0] From 8f0c037e0b72ac174764dd9a8feb94b251d9180c Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 11:11:05 -0400 Subject: [PATCH 25/34] Put more comments in the disk margin example, add example to documentation --- doc/examples/disk_margins.rst | 19 ++++++++++++++ examples/disk_margins.py | 48 +++++++++++++++-------------------- 2 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 doc/examples/disk_margins.rst diff --git a/doc/examples/disk_margins.rst b/doc/examples/disk_margins.rst new file mode 100644 index 000000000..e7938f4ac --- /dev/null +++ b/doc/examples/disk_margins.rst @@ -0,0 +1,19 @@ +Disk margin example +------------------------------------------ + +This example demonstrates the use of the `disk_margins` routine +to compute robust stability margins for a feedback system, i.e., +variation in gain and phase one or more loops. The SISO examples +are drawn from the published paper and the MIMO example is the +"spinning satellite" example from the MathWorks documentation. + +Code +.... +.. literalinclude:: disk_margins.py + :language: python + :linenos: + +Notes +..... +1. The environment variable `PYCONTROL_TEST_EXAMPLES` is used for +testing to turn off plotting of the outputs. diff --git a/examples/disk_margins.py b/examples/disk_margins.py index 44787d3c4..e7b5ab547 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -1,5 +1,25 @@ -"""test_margins.py +"""disk_margins.py + Demonstrate disk-based stability margin calculations. + +References: +[1] Blight, James D., R. Lane Dailey, and Dagfinn Gangsaas. “Practical + Control Law Design for Aircraft Using Multivariable Techniques.” + International Journal of Control 59, no. 1 (January 1994): 93-137. + https://doi.org/10.1080/00207179408923071. + +[2] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction + to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, + no. 5 (October 2020): 78-95. + +[3] P. Benner, V. Mehrmann, V. Sima, S. Van Huffel, and A. Varga, "SLICOT + - A Subroutine Library in Systems and Control Theory", Applied and + Computational Control, Signals, and Circuits (Birkhauser), Vol. 1, Ch. + 10, pp. 505-546, 1999. + +[4] S. Van Huffel, V. Sima, A. Varga, S. Hammarling, and F. Delebecque, + "Development of High Performance Numerical Software for Control", IEEE + Control Systems Magazine, Vol. 24, Nr. 1, Feb., pp. 60-76, 2004. """ import os @@ -32,32 +52,6 @@ def plot_allowable_region(alpha_max, skew, ax = None): PM : ndarray 1D array of frequency-dependent disk-based phase margins, in deg. PM is the same size as "omega" parameter. - - Examples - -------- - >> import control - >> import numpy as np - >> import matplotlib - >> import matplotlib.pyplot as plt - >> - >> omega = np.logspace(-1, 2, 1001) - >> - >> s = control.tf('s') # Laplace variable - >> L = 6.25*(s + 3)*(s + 5)/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) # loop gain - >> - >> DM_plot = [] - >> DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) - >> DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) - >> DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) - >> plt.figure(1) - >> control.disk_margin_plot(DM_plot, skew = [-1.0, 0.0, 1.0]) - >> plt.show() - - References - ---------- - [1] Seiler, Peter, Andrew Packard, and Pascal Gahinet. “An Introduction - to Disk Margins [Lecture Notes].” IEEE Control Systems Magazine 40, - no. 5 (October 2020): 78-95. """ # Create axis if needed From ce80819300de7be5051f2ebed9d87b5793b2257d Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 11:20:12 -0400 Subject: [PATCH 26/34] Fixing docstrings --- control/margins.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/control/margins.py b/control/margins.py index 32835be7e..02f8a1274 100644 --- a/control/margins.py +++ b/control/margins.py @@ -533,6 +533,14 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): omega : sequence of array_like 1D array of (non-negative) frequencies (rad/s) at which to evaluate the disk-based stability margins + skew : float or array_like, optional + skew parameter(s) for disk margin calculation. + skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) + skew = 1 uses the sensitivity function S + skew = -1 uses the complementary sensitivity function T + returnall : bool, optional + If true, return frequency-dependent margins. If False (default), + return only the worst-case (minimum) margins. Returns ------- From 397efabbe7ff9dcc11f2c4309ba73d63ed44d742 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 14:49:32 -0400 Subject: [PATCH 27/34] Corrected expected values for 'no-slycot' condition in newly-added unit tests --- control/tests/margin_test.py | 108 ++++++++++++++++++++++++----------- 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 411a61aec..4569d68fc 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -403,17 +403,35 @@ def test_mimo_disk_margin(): Lo = P*K # loop transfer function, broken at plant output Li = K*P # loop transfer function, broken at plant input - # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0) - assert_allclose([DMo], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMo], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg - - # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0) - assert_allclose([DMi], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMi], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + try: + import slycot + except ImportError: + with pytest.raises(ControlMIMONotImplemented,\ + match = "Need slycot to compute MIMO disk_margins"): + + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0) + assert_allclose([DMo], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMo], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0) + assert_allclose([DMi], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMi], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + else: + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0) + assert_allclose([DMo], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMo], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0) + assert_allclose([DMi], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMi], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg def test_siso_disk_margin_return_all(): # Frequencies of interest @@ -443,24 +461,50 @@ def test_mimo_disk_margin_return_all(): Lo = P*K # loop transfer function, broken at plant output Li = K*P # loop transfer function, broken at plant input - # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0, returnall = True) - assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ - atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMo)], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ - atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ - atol = 0.1) # disk-based phase margin of 21.26 deg - - # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0, returnall = True) - assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ - atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMi)], [0.3754],\ - atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ - atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ - atol = 0.1) # disk-based phase margin of 21.26 deg - + try: + import slycot + except ImportError: + with pytest.raises(ControlMIMONotImplemented,\ + match = "Need slycot to compute MIMO disk_margins"): + + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ + atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMo)], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ + atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ + atol = 0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ + atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMi)], [0.3754],\ + atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ + atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ + atol = 0.1) # disk-based phase margin of 21.26 deg + else: + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ + atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMo)], [0.3754], atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ + atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ + atol = 0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0, returnall = True) + assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ + atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMi)], [0.3754],\ + atol = 0.1) # disk margin of 0.3754 + assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ + atol = 0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ + atol = 0.1) # disk-based phase margin of 21.26 deg From cc027126538301bd9b0fef625ad4f40303886d16 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 26 Apr 2025 14:54:01 -0400 Subject: [PATCH 28/34] Attempt #2 at 397efabbe7ff9dcc11f2c4309ba73d63ed44d742, based on linter recommendation --- control/tests/margin_test.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 4569d68fc..6f7f7728a 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -11,6 +11,7 @@ import pytest from numpy import inf, nan from numpy.testing import assert_allclose +import importlib from control import ControlMIMONotImplemented, FrequencyResponseData, \ StateSpace, TransferFunction, margin, phase_crossover_frequencies, \ @@ -403,9 +404,7 @@ def test_mimo_disk_margin(): Lo = P*K # loop transfer function, broken at plant output Li = K*P # loop transfer function, broken at plant input - try: - import slycot - except ImportError: + if importlib.util.find_spec('slycot') == None: with pytest.raises(ControlMIMONotImplemented,\ match = "Need slycot to compute MIMO disk_margins"): @@ -461,9 +460,7 @@ def test_mimo_disk_margin_return_all(): Lo = P*K # loop transfer function, broken at plant output Li = K*P # loop transfer function, broken at plant input - try: - import slycot - except ImportError: + if importlib.util.find_spec('slycot') == None: with pytest.raises(ControlMIMONotImplemented,\ match = "Need slycot to compute MIMO disk_margins"): From e8897f6fb57d1c9f7b7409055383083cdb59ae68 Mon Sep 17 00:00:00 2001 From: Josiah Date: Mon, 28 Apr 2025 20:04:26 -0400 Subject: [PATCH 29/34] Address @murrayrm review comments. --- control/margins.py | 64 ++++++++--------- control/tests/margin_test.py | 130 ++++++++++++++--------------------- 2 files changed, 84 insertions(+), 110 deletions(-) diff --git a/control/margins.py b/control/margins.py index 02f8a1274..19dfc6638 100644 --- a/control/margins.py +++ b/control/margins.py @@ -522,7 +522,7 @@ def margin(*args): return margin[0], margin[1], margin[3], margin[4] -def disk_margins(L, omega, skew = 0.0, returnall = False): +def disk_margins(L, omega, skew=0.0, returnall=False): """Compute disk-based stability margins for SISO or MIMO LTI loop transfer function. @@ -535,11 +535,11 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): to evaluate the disk-based stability margins skew : float or array_like, optional skew parameter(s) for disk margin calculation. - skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) - skew = 1 uses the sensitivity function S - skew = -1 uses the complementary sensitivity function T + skew = 0.0 (default) uses the "balanced" sensitivity function 0.5*(S - T) + skew = 1.0 uses the sensitivity function S + skew = -1.0 uses the complementary sensitivity function T returnall : bool, optional - If true, return frequency-dependent margins. If False (default), + If True, return frequency-dependent margins. If False (default), return only the worst-case (minimum) margins. Returns @@ -554,11 +554,10 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): Example -------- >> omega = np.logspace(-1, 3, 1001) - >> P = control.ss([[0,10],[-10,0]],np.eye(2),[[1,10],\ - [-10,1]],[[0,0],[0,0]]) - >> K = control.ss([],[],[],[[1,-2],[0,1]]) - >> L = P*K - >> DM, DGM, DPM = control.disk_margins(L, omega, skew = 0.0) + >> P = control.ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10], [-10, 1]], 0) + >> K = control.ss([], [], [], [[1, -2], [0, 1]]) + >> L = P * K + >> DM, DGM, DPM = control.disk_margins(L, omega, skew=0.0) """ # First argument must be a system @@ -571,7 +570,7 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): raise ValueError("Loop gain must be square (n_inputs = n_outputs)") # Need slycot if L is MIMO, for mu calculation - if (not L.issiso()) and (ab13md == None): + if not L.issiso() and ab13md == None: raise ControlMIMONotImplemented(\ "Need slycot to compute MIMO disk_margins") @@ -584,40 +583,42 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): # Compute frequency response of the "balanced" (according # to the skew parameter "sigma") sensitivity function [1-2] - ST = S + 0.5*(skew - 1)*I + ST = S + 0.5 * (skew - 1) * I ST_mag, ST_phase, _ = ST.frequency_response(omega) - ST_jw = (ST_mag*np.exp(1j*ST_phase)) + ST_jw = (ST_mag * np.exp(1j * ST_phase)) if not L.issiso(): - ST_jw = ST_jw.transpose(2,0,1) + ST_jw = ST_jw.transpose(2, 0, 1) # Frequency-dependent complex disk margin, computed using upper bound of # the structured singular value, a.k.a. "mu", of (S + (skew - I)/2). - DM = np.zeros(omega.shape, np.float64) - DGM = np.zeros(omega.shape, np.float64) - DPM = np.zeros(omega.shape, np.float64) - for ii in range(0,len(omega)): + DM = np.zeros(omega.shape) + DGM = np.zeros(omega.shape) + DPM = np.zeros(omega.shape) + for ii in range(0, len(omega)): # Disk margin (a.k.a. "alpha") vs. frequency - if L.issiso() and (ab13md == None): + if L.issiso() and ab13md == None: # For the SISO case, the norm on (S + (skew - I)/2) is # unstructured, and can be computed as the magnitude # of the frequency response. - DM[ii] = 1.0/ST_mag[ii] + DM[ii] = 1.0 / ST_mag[ii] else: # For the MIMO case, the norm on (S + (skew - I)/2) assumes a # single complex uncertainty block diagonal uncertainty # structure. AB13MD provides an upper bound on this norm at # the given frequency omega[ii]. - DM[ii] = 1.0/ab13md(ST_jw[ii], np.array(num_loops*[1]),\ - np.array(num_loops*[2]))[0] + DM[ii] = 1.0 / ab13md(ST_jw[ii], np.array(num_loops * [1]),\ + np.array(num_loops * [2]))[0] # Disk-based gain margin (dB) and phase margin (deg) - with np.errstate(divide = 'ignore', invalid = 'ignore'): + with np.errstate(divide='ignore', invalid='ignore'): # Real-axis intercepts with the disk - gamma_min = (1 - 0.5*DM[ii]*(1 - skew))/(1 + 0.5*DM[ii]*(1 + skew)) - gamma_max = (1 + 0.5*DM[ii]*(1 - skew))/(1 - 0.5*DM[ii]*(1 + skew)) + gamma_min = (1 - 0.5 * DM[ii] * (1 - skew)) \ + / (1 + 0.5 * DM[ii] * (1 + skew)) + gamma_max = (1 + 0.5 * DM[ii] * (1 - skew)) \ + / (1 - 0.5 * DM[ii] * (1 + skew)) # Gain margin (dB) - DGM[ii] = mag2db(np.minimum(1/gamma_min, gamma_max)) + DGM[ii] = mag2db(np.minimum(1 / gamma_min, gamma_max)) if np.isnan(DGM[ii]): DGM[ii] = float('inf') @@ -625,7 +626,7 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): if np.isinf(gamma_max): DPM[ii] = 90.0 else: - DPM[ii] = (1 + gamma_min*gamma_max)/(gamma_min + gamma_max) + DPM[ii] = (1 + gamma_min * gamma_max) / (gamma_min + gamma_max) if abs(DPM[ii]) >= 1.0: DPM[ii] = float('Inf') else: @@ -633,7 +634,7 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): if returnall: # Frequency-dependent disk margin, gain margin and phase margin - return (DM, DGM, DPM) + return DM, DGM, DPM else: # Worst-case disk margin, gain margin and phase margin if DGM.shape[0] and not np.isinf(DGM).all(): @@ -645,6 +646,7 @@ def disk_margins(L, omega, skew = 0.0, returnall = False): if DPM.shape[0]: pmidx = np.where(DPM == np.min(DPM)) - return ((not DM.shape[0] and float('inf')) or np.amin(DM), - (not gmidx != -1 and float('inf')) or DGM[gmidx][0], - (not DPM.shape[0] and float('inf')) or DPM[pmidx][0]) + return ( + float('inf') if DM.shape[0] == 0 else np.amin(DM), + float('inf') if gmidx == -1 else DGM[gmidx][0], + float('inf') if DPM.shape[0] == 0 else DPM[pmidx][0]) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 6f7f7728a..9effec485 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -16,6 +16,7 @@ from control import ControlMIMONotImplemented, FrequencyResponseData, \ StateSpace, TransferFunction, margin, phase_crossover_frequencies, \ stability_margins, disk_margins, tf, ss +from control.exception import slycot_check s = TransferFunction.s @@ -382,55 +383,45 @@ def test_siso_disk_margin(): L = tf(25, [1, 10, 10, 10]) # Balanced (S - T) disk-based stability margins - DM, DGM, DPM = disk_margins(L, omega, skew = 0.0) - assert_allclose([DM], [0.46], atol = 0.1) # disk margin of 0.46 - assert_allclose([DGM], [4.05], atol = 0.1) # disk-based gain margin of 4.05 dB - assert_allclose([DPM], [25.8], atol = 0.1) # disk-based phase margin of 25.8 deg + DM, DGM, DPM = disk_margins(L, omega, skew=0.0) + assert_allclose([DM], [0.46], atol=0.1) # disk margin of 0.46 + assert_allclose([DGM], [4.05], atol=0.1) # disk-based gain margin of 4.05 dB + assert_allclose([DPM], [25.8], atol=0.1) # disk-based phase margin of 25.8 deg # For SISO systems, the S-based (S) disk margin should match the third output # of existing library "stability_margins", i.e., minimum distance from the # Nyquist plot to -1. _, _, SM = stability_margins(L)[:3] - DM = disk_margins(L, omega, skew = 1.0)[0] - assert_allclose([DM], [SM], atol = 0.01) + DM = disk_margins(L, omega, skew=1.0)[0] + assert_allclose([DM], [SM], atol=0.01) def test_mimo_disk_margin(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) # Loop transfer gain - P = ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant - K = ss([],[],[], [[1, -2], [0, 1]]) # controller - Lo = P*K # loop transfer function, broken at plant output - Li = K*P # loop transfer function, broken at plant input + P = ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10], [-10, 1]], 0) # plant + K = ss([], [], [], [[1, -2], [0, 1]]) # controller + Lo = P * K # loop transfer function, broken at plant output + Li = K * P # loop transfer function, broken at plant input - if importlib.util.find_spec('slycot') == None: - with pytest.raises(ControlMIMONotImplemented,\ - match = "Need slycot to compute MIMO disk_margins"): - - # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0) - assert_allclose([DMo], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMo], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg - - # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0) - assert_allclose([DMi], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMi], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg - else: + if slycot_check(): # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0) - assert_allclose([DMo], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMo], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0) + assert_allclose([DMo], [0.3754], atol=0.1) # disk margin of 0.3754 + assert_allclose([DGMo], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0) - assert_allclose([DMi], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMi], [3.3], atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi], [21.26], atol = 0.1) # disk-based phase margin of 21.26 deg + DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0) + assert_allclose([DMi], [0.3754], atol=0.1) # disk margin of 0.3754 + assert_allclose([DGMi], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg + else: + # Slycot not installed. Should throw exception. + with pytest.raises(ControlMIMONotImplemented,\ + match="Need slycot to compute MIMO disk_margins"): + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0) def test_siso_disk_margin_return_all(): # Frequencies of interest @@ -440,68 +431,49 @@ def test_siso_disk_margin_return_all(): L = tf(25, [1, 10, 10, 10]) # Balanced (S - T) disk-based stability margins - DM, DGM, DPM = disk_margins(L, omega, skew = 0.0, returnall = True) + DM, DGM, DPM = disk_margins(L, omega, skew=0.0, returnall=True) assert_allclose([omega[np.argmin(DM)]], [1.94],\ - atol = 0.01) # sensitivity peak at 1.94 rad/s - assert_allclose([min(DM)], [0.46], atol = 0.1) # disk margin of 0.46 + atol=0.01) # sensitivity peak at 1.94 rad/s + assert_allclose([min(DM)], [0.46], atol=0.1) # disk margin of 0.46 assert_allclose([DGM[np.argmin(DM)]], [4.05],\ - atol = 0.1) # disk-based gain margin of 4.05 dB + atol=0.1) # disk-based gain margin of 4.05 dB assert_allclose([DPM[np.argmin(DM)]], [25.8],\ - atol = 0.1) # disk-based phase margin of 25.8 deg + atol=0.1) # disk-based phase margin of 25.8 deg def test_mimo_disk_margin_return_all(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) # Loop transfer gain - P = ss([[0, 10],[-10, 0]], np.eye(2),\ - [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant - K = ss([],[],[], [[1, -2], [0, 1]]) # controller - Lo = P*K # loop transfer function, broken at plant output - Li = K*P # loop transfer function, broken at plant input + P = ss([[0, 10], [-10, 0]], np.eye(2),\ + [[1, 10], [-10, 1]], 0) # plant + K = ss([], [], [], [[1, -2], [0, 1]]) # controller + Lo = P * K # loop transfer function, broken at plant output + Li = K * P # loop transfer function, broken at plant input - if importlib.util.find_spec('slycot') == None: - with pytest.raises(ControlMIMONotImplemented,\ - match = "Need slycot to compute MIMO disk_margins"): - - # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0, returnall = True) - assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ - atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMo)], [0.3754], atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ - atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ - atol = 0.1) # disk-based phase margin of 21.26 deg - - # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0, returnall = True) - assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ - atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMi)], [0.3754],\ - atol = 0.1) # disk margin of 0.3754 - assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ - atol = 0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ - atol = 0.1) # disk-based phase margin of 21.26 deg - else: + if slycot_check(): # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew = 0.0, returnall = True) + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True) assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ - atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMo)], [0.3754], atol = 0.1) # disk margin of 0.3754 + atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMo)], [0.3754], atol=0.1) # disk margin of 0.3754 assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ - atol = 0.1) # disk-based gain margin of 3.3 dB + atol=0.1) # disk-based gain margin of 3.3 dB assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ - atol = 0.1) # disk-based phase margin of 21.26 deg + atol=0.1) # disk-based phase margin of 21.26 deg # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew = 0.0, returnall = True) + DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0, returnall=True) assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ - atol = 0.01) # sensitivity peak at 0 rad/s (or smallest provided) + atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided) assert_allclose([min(DMi)], [0.3754],\ - atol = 0.1) # disk margin of 0.3754 + atol=0.1) # disk margin of 0.3754 assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ - atol = 0.1) # disk-based gain margin of 3.3 dB + atol=0.1) # disk-based gain margin of 3.3 dB assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ - atol = 0.1) # disk-based phase margin of 21.26 deg + atol=0.1) # disk-based phase margin of 21.26 deg + else: + # Slycot not installed. Should throw exception. + with pytest.raises(ControlMIMONotImplemented,\ + match="Need slycot to compute MIMO disk_margins"): + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True) From 579f24b709cc07ec3349ac2c397db9c725bb47b4 Mon Sep 17 00:00:00 2001 From: Josiah Date: Mon, 28 Apr 2025 20:06:11 -0400 Subject: [PATCH 30/34] Update formatting per PEP8/@murrayrm review comments. Add additional reference on disk/ellipse-based margin calculations. --- examples/disk_margins.py | 133 ++++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 65 deletions(-) diff --git a/examples/disk_margins.py b/examples/disk_margins.py index e7b5ab547..0ed772c17 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -20,6 +20,10 @@ [4] S. Van Huffel, V. Sima, A. Varga, S. Hammarling, and F. Delebecque, "Development of High Performance Numerical Software for Control", IEEE Control Systems Magazine, Vol. 24, Nr. 1, Feb., pp. 60-76, 2004. + +[5] Deodhare, G., & Patel, V. (1998, August). A "Modern" Look at Gain + and Phase Margins: An H-Infinity/mu Approach. In Guidance, Navigation, + and Control Conference and Exhibit (p. 4134). """ import os @@ -27,7 +31,7 @@ import matplotlib.pyplot as plt import numpy as np -def plot_allowable_region(alpha_max, skew, ax = None): +def plot_allowable_region(alpha_max, skew, ax=None): """Plot region of allowable gain/phase variation, given worst-case disk margin. Parameters @@ -36,9 +40,9 @@ def plot_allowable_region(alpha_max, skew, ax = None): worst-case disk margin(s) across all frequencies. May be a scalar or list. skew : float (scalar or list) skew parameter(s) for disk margin calculation. - skew = 0 uses the "balanced" sensitivity function 0.5*(S - T) - skew = 1 uses the sensitivity function S - skew = -1 uses the complementary sensitivity function T + skew=0 uses the "balanced" sensitivity function 0.5*(S - T) + skew=1 uses the sensitivity function S + skew=-1 uses the complementary sensitivity function T ax : axes to plot bounding curve(s) onto Returns @@ -65,31 +69,30 @@ def plot_allowable_region(alpha_max, skew, ax = None): alpha_max = np.asarray(alpha_max) if np.isscalar(skew): - skew = np.asarray([skew]) + skew=np.asarray([skew]) else: - skew = np.asarray(skew) + skew=np.asarray(skew) # Add a plot for each (alpha, skew) pair present theta = np.linspace(0, np.pi, 500) legend_list = [] for ii in range(0, skew.shape[0]): - legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %( + legend_str = "$\\sigma$ = %.1f, $\\alpha_{max}$ = %.2f" %(\ skew[ii], alpha_max[ii]) legend_list.append(legend_str) # Complex bounding curve of stable gain/phase variations - f = (2 + alpha_max[ii]*(1 - skew[ii])*np.exp(1j*theta))/\ - (2 - alpha_max[ii]*(1 + skew[ii])*np.exp(1j*theta)) + f = (2 + alpha_max[ii] * (1 - skew[ii]) * np.exp(1j * theta))\ + /(2 - alpha_max[ii] * (1 + skew[ii]) * np.exp(1j * theta)) # Allowable combined gain/phase variations gamma_dB = control.ctrlutil.mag2db(np.abs(f)) # gain margin (dB) phi_deg = np.rad2deg(np.angle(f)) # phase margin (deg) # Plot the allowable combined gain/phase variations - out = ax.plot(gamma_dB, phi_deg, alpha = 0.25, - label = '_nolegend_') - ax.fill_between(ax.lines[ii].get_xydata()[:,0], - ax.lines[ii].get_xydata()[:,1], alpha = 0.25) + out = ax.plot(gamma_dB, phi_deg, alpha=0.25, label='_nolegend_') + ax.fill_between(ax.lines[ii].get_xydata()[:,0],\ + ax.lines[ii].get_xydata()[:,1], alpha=0.25) plt.ylabel('Phase Variation (deg)') plt.xlabel('Gain Variation (dB)') @@ -119,7 +122,7 @@ def test_siso1(): print(f"PM_ = {PM_} deg\n") print("------------- Sensitivity function (S) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) + DM, GM, PM = control.disk_margins(L, omega, skew=1.0, returnall=True) # S-based (S) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -127,7 +130,7 @@ def test_siso1(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(1) - plt.subplot(3,3,1) + plt.subplot(3, 3, 1) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -137,7 +140,7 @@ def test_siso1(): plt.ylim([0, 2]) plt.figure(1) - plt.subplot(3,3,4) + plt.subplot(3, 3, 4) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -147,7 +150,7 @@ def test_siso1(): plt.ylim([0, 40]) plt.figure(1) - plt.subplot(3,3,7) + plt.subplot(3, 3, 7) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -158,7 +161,7 @@ def test_siso1(): plt.xlabel('Frequency (rad/s)') print("------------- Complementary sensitivity function (T) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) + DM, GM, PM = control.disk_margins(L, omega, skew=-1.0, returnall=True) # T-based (T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -166,7 +169,7 @@ def test_siso1(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(1) - plt.subplot(3,3,2) + plt.subplot(3, 3, 2) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -176,7 +179,7 @@ def test_siso1(): plt.ylim([0, 2]) plt.figure(1) - plt.subplot(3,3,5) + plt.subplot(3, 3, 5) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -186,7 +189,7 @@ def test_siso1(): plt.ylim([0, 40]) plt.figure(1) - plt.subplot(3,3,8) + plt.subplot(3, 3, 8) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -197,7 +200,7 @@ def test_siso1(): plt.xlabel('Frequency (rad/s)') print("------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + DM, GM, PM = control.disk_margins(L, omega, skew=0.0, returnall=True) # balanced (S - T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -205,7 +208,7 @@ def test_siso1(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(1) - plt.subplot(3,3,3) + plt.subplot(3, 3, 3) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -215,7 +218,7 @@ def test_siso1(): plt.ylim([0, 2]) plt.figure(1) - plt.subplot(3,3,6) + plt.subplot(3, 3, 6) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -225,7 +228,7 @@ def test_siso1(): plt.ylim([0, 40]) plt.figure(1) - plt.subplot(3,3,9) + plt.subplot(3, 3, 9) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -237,11 +240,11 @@ def test_siso1(): # Disk margin plot of admissible gain/phase variations for which DM_plot = [] - DM_plot.append(control.disk_margins(L, omega, skew = -2.0)[0]) - DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) - DM_plot.append(control.disk_margins(L, omega, skew = 2.0)[0]) + DM_plot.append(control.disk_margins(L, omega, skew=-2.0)[0]) + DM_plot.append(control.disk_margins(L, omega, skew=0.0)[0]) + DM_plot.append(control.disk_margins(L, omega, skew=2.0)[0]) plt.figure(10); plt.clf() - plot_allowable_region(DM_plot, skew = [-2.0, 0.0, 2.0]) + plot_allowable_region(DM_plot, skew=[-2.0, 0.0, 2.0]) return @@ -258,7 +261,7 @@ def test_siso2(): s = control.tf('s') # Loop transfer gain - L = (6.25*(s + 3)*(s + 5))/(s*(s + 1)**2*(s**2 + 0.18*s + 100)) + L = (6.25 * (s + 3) * (s + 5)) / (s * (s + 1)**2 * (s**2 + 0.18 * s + 100)) print("------------- Python control built-in (S) -------------") GM_, PM_, SM_ = control.stability_margins(L)[:3] # python-control default (S-based...?) @@ -267,7 +270,7 @@ def test_siso2(): print(f"PM_ = {PM_} deg\n") print("------------- Sensitivity function (S) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) + DM, GM, PM = control.disk_margins(L, omega, skew=1.0, returnall=True) # S-based (S) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -275,7 +278,7 @@ def test_siso2(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(2) - plt.subplot(3,3,1) + plt.subplot(3, 3, 1) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -285,7 +288,7 @@ def test_siso2(): plt.ylim([0, 2]) plt.figure(2) - plt.subplot(3,3,4) + plt.subplot(3, 3, 4) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -295,7 +298,7 @@ def test_siso2(): plt.ylim([0, 40]) plt.figure(2) - plt.subplot(3,3,7) + plt.subplot(3, 3, 7) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -306,7 +309,7 @@ def test_siso2(): plt.xlabel('Frequency (rad/s)') print("------------- Complementary sensitivity function (T) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) + DM, GM, PM = control.disk_margins(L, omega, skew=-1.0, returnall=True) # T-based (T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -314,7 +317,7 @@ def test_siso2(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(2) - plt.subplot(3,3,2) + plt.subplot(3, 3, 2) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -324,7 +327,7 @@ def test_siso2(): plt.ylim([0, 2]) plt.figure(2) - plt.subplot(3,3,5) + plt.subplot(3, 3, 5) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -334,7 +337,7 @@ def test_siso2(): plt.ylim([0, 40]) plt.figure(2) - plt.subplot(3,3,8) + plt.subplot(3, 3, 8) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -345,7 +348,7 @@ def test_siso2(): plt.xlabel('Frequency (rad/s)') print("------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + DM, GM, PM = control.disk_margins(L, omega, skew=0.0, returnall=True) # balanced (S - T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -353,7 +356,7 @@ def test_siso2(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(2) - plt.subplot(3,3,3) + plt.subplot(3, 3, 3) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -363,7 +366,7 @@ def test_siso2(): plt.ylim([0, 2]) plt.figure(2) - plt.subplot(3,3,6) + plt.subplot(3, 3, 6) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -373,7 +376,7 @@ def test_siso2(): plt.ylim([0, 40]) plt.figure(2) - plt.subplot(3,3,9) + plt.subplot(3, 3, 9) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -386,11 +389,11 @@ def test_siso2(): # Disk margin plot of admissible gain/phase variations for which # the feedback loop still remains stable, for each skew parameter DM_plot = [] - DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) - DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) - DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) + DM_plot.append(control.disk_margins(L, omega, skew=-1.0)[0]) # T-based (T) + DM_plot.append(control.disk_margins(L, omega, skew=0.0)[0]) # balanced (S - T) + DM_plot.append(control.disk_margins(L, omega, skew=1.0)[0]) # S-based (S) plt.figure(20) - plot_allowable_region(DM_plot, skew = [-1.0, 0.0, 1.0]) + plot_allowable_region(DM_plot, skew=[-1.0, 0.0, 1.0]) return @@ -404,12 +407,12 @@ def test_mimo(): omega = np.logspace(-1, 3, 1001) # Loop transfer gain - P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], [[0, 0],[0, 0]]) # plant - K = control.ss([],[],[], [[1, -2], [0, 1]]) # controller - L = P*K # loop gain + P = control.ss([[0, 10],[-10, 0]], np.eye(2), [[1, 10], [-10, 1]], 0) # plant + K = control.ss([], [], [], [[1, -2], [0, 1]]) # controller + L = P * K # loop gain print("------------- Sensitivity function (S) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = 1.0, returnall = True) # S-based (S) + DM, GM, PM = control.disk_margins(L, omega, skew=1.0, returnall=True) # S-based (S) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -417,7 +420,7 @@ def test_mimo(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(3) - plt.subplot(3,3,1) + plt.subplot(3, 3, 1) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -427,7 +430,7 @@ def test_mimo(): plt.ylim([0, 2]) plt.figure(3) - plt.subplot(3,3,4) + plt.subplot(3, 3, 4) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -437,7 +440,7 @@ def test_mimo(): plt.ylim([0, 40]) plt.figure(3) - plt.subplot(3,3,7) + plt.subplot(3, 3, 7) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -448,7 +451,7 @@ def test_mimo(): plt.xlabel('Frequency (rad/s)') print("------------- Complementary sensitivity function (T) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = -1.0, returnall = True) # T-based (T) + DM, GM, PM = control.disk_margins(L, omega, skew=-1.0, returnall=True) # T-based (T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -456,7 +459,7 @@ def test_mimo(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(3) - plt.subplot(3,3,2) + plt.subplot(3, 3, 2) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -466,7 +469,7 @@ def test_mimo(): plt.ylim([0, 2]) plt.figure(3) - plt.subplot(3,3,5) + plt.subplot(3, 3, 5) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -476,7 +479,7 @@ def test_mimo(): plt.ylim([0, 40]) plt.figure(3) - plt.subplot(3,3,8) + plt.subplot(3, 3, 8) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -487,7 +490,7 @@ def test_mimo(): plt.xlabel('Frequency (rad/s)') print("------------- Balanced sensitivity function (S - T) -------------") - DM, GM, PM = control.disk_margins(L, omega, skew = 0.0, returnall = True) # balanced (S - T) + DM, GM, PM = control.disk_margins(L, omega, skew=0.0, returnall=True) # balanced (S - T) print(f"min(DM) = {min(DM)} (omega = {omega[np.argmin(DM)]})") print(f"GM = {GM[np.argmin(DM)]} dB") print(f"PM = {PM[np.argmin(DM)]} deg") @@ -495,7 +498,7 @@ def test_mimo(): print(f"min(PM) = {min(PM)} deg\n") plt.figure(3) - plt.subplot(3,3,3) + plt.subplot(3, 3, 3) plt.semilogx(omega, DM, label='$\\alpha$') plt.ylabel('Disk Margin (abs)') plt.legend() @@ -505,7 +508,7 @@ def test_mimo(): plt.ylim([0, 2]) plt.figure(3) - plt.subplot(3,3,6) + plt.subplot(3, 3, 6) plt.semilogx(omega, GM, label='$\\gamma_{m}$') plt.ylabel('Gain Margin (dB)') plt.legend() @@ -515,7 +518,7 @@ def test_mimo(): plt.ylim([0, 40]) plt.figure(3) - plt.subplot(3,3,9) + plt.subplot(3, 3, 9) plt.semilogx(omega, PM, label='$\\phi_{m}$') plt.ylabel('Phase Margin (deg)') plt.legend() @@ -528,11 +531,11 @@ def test_mimo(): # Disk margin plot of admissible gain/phase variations for which # the feedback loop still remains stable, for each skew parameter DM_plot = [] - DM_plot.append(control.disk_margins(L, omega, skew = -1.0)[0]) # T-based (T) - DM_plot.append(control.disk_margins(L, omega, skew = 0.0)[0]) # balanced (S - T) - DM_plot.append(control.disk_margins(L, omega, skew = 1.0)[0]) # S-based (S) + DM_plot.append(control.disk_margins(L, omega, skew=-1.0)[0]) # T-based (T) + DM_plot.append(control.disk_margins(L, omega, skew=0.0)[0]) # balanced (S - T) + DM_plot.append(control.disk_margins(L, omega, skew=1.0)[0]) # S-based (S) plt.figure(30) - plot_allowable_region(DM_plot, skew = [-1.0, 0.0, 1.0]) + plot_allowable_region(DM_plot, skew=[-1.0, 0.0, 1.0]) return From fe79760dcaafe6ada55dd40390e2b0e6a08b2b52 Mon Sep 17 00:00:00 2001 From: Josiah Date: Mon, 28 Apr 2025 20:08:20 -0400 Subject: [PATCH 31/34] Follow-on to e8897f6fb57d1c9f7b7409055383083cdb59ae68: remove now-unnecessary import of importlib --- control/tests/margin_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 9effec485..23ef00aac 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -11,7 +11,6 @@ import pytest from numpy import inf, nan from numpy.testing import assert_allclose -import importlib from control import ControlMIMONotImplemented, FrequencyResponseData, \ StateSpace, TransferFunction, margin, phase_crossover_frequencies, \ From b85147e7020c6a1e6bad9713cff5fed68e6c1de0 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 21 Jun 2025 20:00:22 -0400 Subject: [PATCH 32/34] Update formatting per @murrayrm review comments --- control/margins.py | 50 ++++++++++++++++++++++------------------ doc/functions.rst | 2 +- examples/disk_margins.py | 4 ---- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/control/margins.py b/control/margins.py index 19dfc6638..3fb634578 100644 --- a/control/margins.py +++ b/control/margins.py @@ -20,7 +20,7 @@ except ImportError: ab13md = None -__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin',\ +__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin', 'disk_margins'] # private helper functions @@ -173,6 +173,7 @@ def fun(wdt): return z, w + def _likely_numerical_inaccuracy(sys): # crude, conservative check for if # num(z)*num(1/z) << den(z)*den(1/z) for DT systems @@ -468,6 +469,7 @@ def phase_crossover_frequencies(sys): return omega, gains + def margin(*args): """ margin(sys) \ @@ -522,25 +524,26 @@ def margin(*args): return margin[0], margin[1], margin[3], margin[4] + def disk_margins(L, omega, skew=0.0, returnall=False): - """Compute disk-based stability margins for SISO or MIMO LTI - loop transfer function. + """Disk-based stability margins of loop transfer function. +---------------------------------------------------------------- Parameters ---------- L : `StateSpace` or `TransferFunction` - Linear SISO or MIMO loop transfer function system + Linear SISO or MIMO loop transfer function. omega : sequence of array_like 1D array of (non-negative) frequencies (rad/s) at which - to evaluate the disk-based stability margins + to evaluate the disk-based stability margins. skew : float or array_like, optional - skew parameter(s) for disk margin calculation. - skew = 0.0 (default) uses the "balanced" sensitivity function 0.5*(S - T) - skew = 1.0 uses the sensitivity function S - skew = -1.0 uses the complementary sensitivity function T + skew parameter(s) for disk margin (default = 0.0). + skew = 0.0 (default) "balanced" sensitivity 0.5*(S - T). + skew = 1.0 sensitivity function S. + skew = -1.0 complementary sensitivity function T. returnall : bool, optional - If True, return frequency-dependent margins. If False (default), - return only the worst-case (minimum) margins. + If True, return frequency-dependent margins. + If False (default), return worst-case (minimum) margins. Returns ------- @@ -554,7 +557,8 @@ def disk_margins(L, omega, skew=0.0, returnall=False): Example -------- >> omega = np.logspace(-1, 3, 1001) - >> P = control.ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10], [-10, 1]], 0) + >> P = control.ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10], + [-10, 1]], 0) >> K = control.ss([], [], [], [[1, -2], [0, 1]]) >> L = P * K >> DM, DGM, DPM = control.disk_margins(L, omega, skew=0.0) @@ -562,7 +566,7 @@ def disk_margins(L, omega, skew=0.0, returnall=False): # First argument must be a system if not isinstance(L, (statesp.StateSpace, xferfcn.TransferFunction)): - raise ValueError(\ + raise ValueError( "Loop gain must be state-space or transfer function object") # Loop transfer function must be square @@ -571,7 +575,7 @@ def disk_margins(L, omega, skew=0.0, returnall=False): # Need slycot if L is MIMO, for mu calculation if not L.issiso() and ab13md == None: - raise ControlMIMONotImplemented(\ + raise ControlMIMONotImplemented( "Need slycot to compute MIMO disk_margins") # Get dimensions of feedback system @@ -589,8 +593,9 @@ def disk_margins(L, omega, skew=0.0, returnall=False): if not L.issiso(): ST_jw = ST_jw.transpose(2, 0, 1) - # Frequency-dependent complex disk margin, computed using upper bound of - # the structured singular value, a.k.a. "mu", of (S + (skew - I)/2). + # Frequency-dependent complex disk margin, computed using + # upper bound of the structured singular value, a.k.a. "mu", + # of (S + (skew - I)/2). DM = np.zeros(omega.shape) DGM = np.zeros(omega.shape) DPM = np.zeros(omega.shape) @@ -602,11 +607,11 @@ def disk_margins(L, omega, skew=0.0, returnall=False): # of the frequency response. DM[ii] = 1.0 / ST_mag[ii] else: - # For the MIMO case, the norm on (S + (skew - I)/2) assumes a - # single complex uncertainty block diagonal uncertainty - # structure. AB13MD provides an upper bound on this norm at - # the given frequency omega[ii]. - DM[ii] = 1.0 / ab13md(ST_jw[ii], np.array(num_loops * [1]),\ + # For the MIMO case, the norm on (S + (skew - I)/2) + # assumes a single complex uncertainty block diagonal + # uncertainty structure. AB13MD provides an upper bound + # on this norm at the given frequency omega[ii]. + DM[ii] = 1.0 / ab13md(ST_jw[ii], np.array(num_loops * [1]), np.array(num_loops * [2]))[0] # Disk-based gain margin (dB) and phase margin (deg) @@ -626,7 +631,8 @@ def disk_margins(L, omega, skew=0.0, returnall=False): if np.isinf(gamma_max): DPM[ii] = 90.0 else: - DPM[ii] = (1 + gamma_min * gamma_max) / (gamma_min + gamma_max) + DPM[ii] = (1 + gamma_min * gamma_max) \ + / (gamma_min + gamma_max) if abs(DPM[ii]) >= 1.0: DPM[ii] = float('Inf') else: diff --git a/doc/functions.rst b/doc/functions.rst index 3d3614a9b..8432f7fcf 100644 --- a/doc/functions.rst +++ b/doc/functions.rst @@ -142,6 +142,7 @@ Frequency domain analysis: bandwidth dcgain + disk_margins linfnorm margin stability_margins @@ -150,7 +151,6 @@ Frequency domain analysis: singular_values_plot singular_values_response sisotool - disk_margins Pole/zero-based analysis: diff --git a/examples/disk_margins.py b/examples/disk_margins.py index 0ed772c17..1b9934156 100644 --- a/examples/disk_margins.py +++ b/examples/disk_margins.py @@ -546,7 +546,3 @@ def test_mimo(): if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: #plt.tight_layout() plt.show() - - - - From eb3af29c6cb17e399e50e46ab275e6f5b3bba74c Mon Sep 17 00:00:00 2001 From: Josiah Date: Sat, 21 Jun 2025 20:10:17 -0400 Subject: [PATCH 33/34] Remove temporarily-added string from docstring --- control/margins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/margins.py b/control/margins.py index 3fb634578..f06331a84 100644 --- a/control/margins.py +++ b/control/margins.py @@ -527,7 +527,6 @@ def margin(*args): def disk_margins(L, omega, skew=0.0, returnall=False): """Disk-based stability margins of loop transfer function. ----------------------------------------------------------------- Parameters ---------- From bb06c9edc4670f94ad9551050d75cb3d47d2ec81 Mon Sep 17 00:00:00 2001 From: Josiah Date: Sun, 22 Jun 2025 10:00:01 -0400 Subject: [PATCH 34/34] Minor tweak to docstring to fit the word 'function' back into the description of skew = 0.0 --- control/margins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/margins.py b/control/margins.py index f06331a84..e51aa40af 100644 --- a/control/margins.py +++ b/control/margins.py @@ -537,7 +537,7 @@ def disk_margins(L, omega, skew=0.0, returnall=False): to evaluate the disk-based stability margins. skew : float or array_like, optional skew parameter(s) for disk margin (default = 0.0). - skew = 0.0 (default) "balanced" sensitivity 0.5*(S - T). + skew = 0.0 "balanced" sensitivity function 0.5*(S - T). skew = 1.0 sensitivity function S. skew = -1.0 complementary sensitivity function T. returnall : bool, optional