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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy