Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support multiple gain margins #784

Open
shaikinast opened this issue Oct 26, 2022 · 14 comments
Open

support multiple gain margins #784

shaikinast opened this issue Oct 26, 2022 · 14 comments

Comments

@shaikinast
Copy link

Hello,

I'm computing stability margins of a system.

The phase margin (PM) is computed correctly, but the gain margin (GM) shows zero (actually it shows 1E-16).

Analyzing the bode plot of the open-loop (OL) transfer function, it clearly shows that the GM should be infinite, since the phase of the

OL never crosses the -180 deg line (it only converges to it asymptotically as omega approaces infinity).

See below the bode plot of the OL, including stability margins.

Any idea why it happens?

Thanks,
-Shai

zero_gain_margin

@murrayrm
Copy link
Member

murrayrm commented Oct 26, 2022

@shaikinast Can you add the code that was used to generate the plot? That will help us figure out why you are getting that result.

@shaikinast
Copy link
Author

shaikinast commented Oct 30, 2022

Sure, please see below the code.

Bode diagrams of two almost identical open-loop transfer functions are plotted.

The stability margins are printed.

In the first case, the gain margin correctly shows Inf, as approved by the Bode diagram.

In the second case, the gain margin wrongly shows zero, contrary to the Bode diagram.

Your advice is highly appreciated!

Thanks,
-Shai

import numpy as np
import control as cl
import matplotlib.pyplot as plt


def par_h():
    par = dict()
    par['P_nom'] = 22e6
    par['P'] = 22e6
    par['Lambda'] = 1.68e-3
    par['beta_i'] = np.array([14.2, 92.4, 78.0, 206.6, 67.1, 21.8]) * 1e-5
    par['lambda_i'] = np.array([0.0127, 0.0317, 0.116, 0.311, 1.40, 3.87])
    par['q'] = 0.96
    par['c_f'] = 977
    par['c_m'] = 1697.0
    par['c_c'] = 5188.6
    par['m_f'] = 2002.0
    par['m_m'] = 11573.0
    par['m_c'] = 500.0
    par['m_dot'] = 17.5
    par['Tf0'] = 1105
    par['Tm0'] = 1087
    par['T_in'] = 864.0
    par['T_out'] = 1106.0
    par['alpha_f'] = -2.875e-5
    par['alpha_m'] = -3.696e-5
    par['beta'] = np.sum(par['beta_i'])
    par['Tc0'] = (par['T_in'] + par['T_out']) / 2.0
    par['Kfm'] = par['q'] * par['P_nom'] / (par['Tf0'] - par['Tm0'])
    par['Kmc'] = par['P_nom'] / (par['Tm0'] - par['Tc0'])

    return par


def tf_h(par, **kwargs):
    par.update(kwargs)
    num_del = len(par['beta_i'])
    nv = num_del + 1

    G0 = dict()
    H = dict()
    sys = dict()
    tf = dict()

    G0['A'] = np.zeros([nv, nv])
    G0['B'] = np.zeros([nv, 1])
    G0['C'] = np.zeros(nv)
    G0['D'] = np.zeros(1)

    G0['A'][0, 0] = -par['beta']/par['Lambda']
    for i in range(1, nv):
        G0['A'][0, i] = par['beta_i'][i-1] / par['Lambda']
        G0['A'][i, 0] = par['lambda_i'][i-1]
        G0['A'][i, i] = -par['lambda_i'][i - 1]
    G0['B'][0] = 1 / par['Lambda']
    G0['C'][0] = 1

    H['A'] = np.zeros([3, 3])
    H['B'] = np.zeros([3, 1])
    H['C'] = dict()
    H['D'] = np.zeros(1)

    H['A'][0, 0] = -par['Kfm'] / (par['m_f'] * par['c_f'])
    H['A'][0, 1] = par['Kfm'] / (par['m_f'] * par['c_f'])
    H['A'][1, 0] = par['Kfm'] / (par['m_m'] * par['c_m'])
    H['A'][1, 1] = -(par['Kfm'] + par['Kmc']) / (par['m_m'] * par['c_m'])
    H['A'][1, 2] = par['Kmc'] / (par['m_m'] * par['c_m'])
    H['A'][2, 1] = par['Kmc'] / (par['m_c'] * par['c_c'])
    H['A'][2, 2] = -(par['Kmc'] + 2 * par['m_dot'] * par['c_c']) / (par['m_c'] * par['c_c'])
    H['B'][0] = par['q'] / (par['m_f'] * par['c_f'])
    H['B'][1] = (1 - par['q']) / (par['m_m'] * par['c_m'])
    H['C']['f'] = np.array([1, 0, 0])
    H['C']['m'] = np.array([0, 1, 0])
    H['C']['c'] = np.array([0, 0, 1])

    sys['G0'] = cl.ss(G0['A'], G0['B'], G0['C'], G0['D'])
    sys['H_f'] = cl.ss(H['A'], H['B'], H['C']['f'], H['D'])
    sys['H_m'] = cl.ss(H['A'], H['B'], H['C']['m'], H['D'])
    sys['H_c'] = cl.ss(H['A'], H['B'], H['C']['c'], H['D'])
    sys['H'] = par['P'] * (par['alpha_f'] * sys['H_f'] +
                           par['alpha_m'] * sys['H_m'])

    tf['G0'] = cl.ss2tf(sys['G0'])
    tf['H'] = cl.ss2tf(sys['H'])
    tf['G'] = tf['G0'] / (1 - tf['G0'] * tf['H'])

    return tf


def calc_margins(par):
    tf = tf_h(par)
    ol_tf = -tf['G0'] * tf['H']  # open-loop transfer function
    margin = cl.margin(ol_tf)
    plt.figure()
    cl.bode(ol_tf, margins=True, omega=list(np.logspace(-3, 2)))
    print(margin)


par = par_h()
calc_margins(par)

par['lambda_i'][5] = 5
calc_margins(par)


plt.show()

@murrayrm
Copy link
Member

murrayrm commented Oct 31, 2022

If you look at the Nyquist plots for the two systems, you can see why you are getting different answers:

Both systems are stable in closed loop, but the first system is open loop unstable and the second system is open loop stable. And the first system is tricky since while it is true that you can multiply the gain by a number greater than 1 and not get any new encirclements, if you multiply the gain by a very small number, the system will eventually lose one encirclement and the closed loop system becomes unstable.

If you expand out the range of the Bode plot, you can also see what is going on. Here are the results with omega=np.logspace(-18, 2):

Note that in both systems there is a pole at very low frequency (~1e-17).

This example shows one of the challenges in determining the gain margin for a system that is unstable in the open loop: if you make the loop gain small enough, you end up with an unstable system! This type of situation is sometimes called "conditional stability" and is discussed briefly in FBS (just before Section 10.3).

It is not really clear what the definition of the gain margin should be for a system that is conditionally stable. In FBS we define gain margin as "the smallest multiplier of the loop gain that makes the system unstable". By that definition, the computation for the first system is correct: if you multiply the loop gain by 0 (the smallest possible loop gain), you get an unstable system!

@sawyerbfuller
Copy link
Contributor

Another term I have heard used for this is "negative gain margin." Not because the gain is a negative number, but because when converted to decibels, the result is negative (i.e. a number below 1 is a negative number of decibels).

It might be worth incorporating the ability to compute negative gain margins, which it looks like is not currently supported in the library.

@sawyerbfuller
Copy link
Contributor

Matlab offers an example of negative gain margin here: https://www.mathworks.com/help/control/ug/assessing-gain-and-phase-margins.html

I'll turn this into an enhancement feature request.

@sawyerbfuller sawyerbfuller changed the title Zero gain margin support negative gain margins Oct 31, 2022
@roryyorke
Copy link
Contributor

Don't we already support finding margins of conditionally stable systems?

In [11]: import control as ct

In [12]: g = ct.tf([1],[1,-1])

In [13]: ct.margin(g*2)
Out[13]: (0.5, 60.0, 0.0, 1.7320508075688772)

@sawyerbfuller
Copy link
Contributor

@roryyorke good point. changing the title to indicate support for multiple margins, like allmargins in Matlab.

@sawyerbfuller sawyerbfuller changed the title support negative gain margins support mulitple gain margins Nov 7, 2022
@shaikinast
Copy link
Author

Thank you all for the comments!

Some follow-ups:

  1. I think I (somehow) understand the concept of gain margins smaller than one (or negative in dB), but the asymptotic case of GM approaching zero still puzzles me. Does this make sense, that by multiplying the gain by zero the system will become unstable?
  2. Specifically, in the system that I am modelling (point model of a nuclear reactor with an intrinsic feedback), the gain is modelled by the reactor power (I can elaborate...), and it seems unphysical that the system will loose stability for zero power. Another option is, of course, that something is wrong in the model description, although it is a standard and well-studied model.
  3. @murrayrm I am able to generate the same Bode plots that you show, but not the Nyquist diagrams, I tried control.nyquist(open-loop-TF), but got totally different plots. Can you please share the code for these diagrams?

@bnavigator bnavigator changed the title support mulitple gain margins support multiple gain margins Nov 9, 2022
@roryyorke
Copy link
Contributor

Does this make sense, that by multiplying the gain by zero the system will become unstable?

Consider transfer function g(s) = a/(s-1) with a>0.

The closed loop TF is h(s) = a/(s-1) / (1 + a/(s-1)) = a / (s-1+a)

For stability, we require a-1>0 => a>1.

Let a = k * a0, with a0 > 1 and k > 0. System is stable for k*a0 > 1 => k > 1/a0. As a0 tends to infinity, the lower bound on k approaches 0.

There's also an intuitive argument: if the system to be controlled is open-loop unstable, then feedback is required for stability; setting the feedback gain to 0 disables feedback, and must make the system unstable. In the above example, as the stabilizing factor a0 gets large, the gain margin k also becomes larger in multiplicative (dB) terms.

Specifically, in the system that I am modelling (point model of a nuclear reactor with an intrinsic feedback), the gain is modelled by the reactor power (I can elaborate...), and it seems unphysical that the system will loose stability for zero power.

Is the system open-loop unstable?

@sawyerbfuller

changing the title to indicate support for multiple margins, like allmargins in Matlab.

We do have stability_margins(..., returnall=True) .

@murrayrm
Copy link
Member

For the Nyquist plots that I showed, I just used cl.nyquist_plot(ol_tf). This can be done by modifying the calc_margins function:

def calc_margins(par):
    tf = tf_h(par)
    ol_tf = -tf['G0'] * tf['H']  # open-loop transfer function
    margin = cl.margin(ol_tf)
    plt.figure()
    cl.bode(ol_tf, margins=True, omega=list(np.logspace(-18, 2)))
    plt.figure()
    cl.nyquist_plot(ol_tf)
    print(margin)

@sawyerbfuller
Copy link
Contributor

changing the title to indicate support for multiple margins, like allmargins in Matlab.

We do have stability_margins(..., returnall=True) .

Good point. Looks like we do support that already. I'll make sure I have time to check first before I make future changes to this thread.

To that end - this sounds like a system in which there is a minimum gain, below which it goes unstable. I think this is the definition of a negative gain margin - how much lower can the gain go before going unstable. Does that sound right?

@shaikinast suggests that the library gives this value as something like 1e-16, but I have not gotten around to check if that number is correct.

@roryyorke
Copy link
Contributor

Conclusion: it might be good to compute margins of SS systems without converting to TF, but in this case the benefit would be negligible; the difference between a gain margin of inf and 0 is not of practical significance. It would be interesting to find examples where the TF form gives significantly different result: perhaps one with many lightly damped poles over a wide frequency range?

By adapting @shaikinast 's script, we can see this example suffers from conversion to TF. (Code is at the end.)

Here's a Bode plot of the two loop transfer functions as state-space systems; this show's @shaikinast 's intuition that the stability should not have changed is correct:

image

Here are the Bode plots as transfer functions (this is the similar to @murrayrm 's result above, though the frequencies look a bit different; not sure it matters):

image

The calculated margins between SS and TF are exactly the same; this is because margin transforms its arguments to TF:

ct.margin(l1)=(1.9079774436399446e-11, 59.30180279370231, 0.0, 0.047580613328943165)
ct.margin(l2)=(inf, 59.32066896287989, nan, 0.04759166623997706)
ct.margin(l1_tf)=(1.9079774436399446e-11, 59.30180279370231, 0.0, 0.047580613328943165)
ct.margin(l2_tf)=(inf, 59.32066896287989, nan, 0.04759166623997706)

The almost-0 margin of the TF form is due to one pole of the open loop TF being just in the RHP. All the other poles, and all the zeros, are in the LHP plane, so it's not surprising that the merest sliver of gain makes it stable.

np.sort((l1_tf.pole().real))[-2:]=array([-4.40194904e-03,  5.18021816e-12])
max(l1_tf.zero().real)=-0.004401949315354439

This is borne out by reducing the gain to nearly zero and checking the closed-loop pole position:

image

import numpy as np
import control as ct
import matplotlib.pyplot as plt

def par_h():
    par = dict()
    par['P_nom'] = 22e6
    par['P'] = 22e6
    par['Lambda'] = 1.68e-3
    par['beta_i'] = np.array([14.2, 92.4, 78.0, 206.6, 67.1, 21.8]) * 1e-5
    par['lambda_i'] = np.array([0.0127, 0.0317, 0.116, 0.311, 1.40, 3.87])
    par['q'] = 0.96
    par['c_f'] = 977
    par['c_m'] = 1697.0
    par['c_c'] = 5188.6
    par['m_f'] = 2002.0
    par['m_m'] = 11573.0
    par['m_c'] = 500.0
    par['m_dot'] = 17.5
    par['Tf0'] = 1105
    par['Tm0'] = 1087
    par['T_in'] = 864.0
    par['T_out'] = 1106.0
    par['alpha_f'] = -2.875e-5
    par['alpha_m'] = -3.696e-5
    par['beta'] = np.sum(par['beta_i'])
    par['Tc0'] = (par['T_in'] + par['T_out']) / 2.0
    par['Kfm'] = par['q'] * par['P_nom'] / (par['Tf0'] - par['Tm0'])
    par['Kmc'] = par['P_nom'] / (par['Tm0'] - par['Tc0'])

    return par


def sys_h(par, **kwargs):
    par.update(kwargs)
    num_del = len(par['beta_i'])
    nv = num_del + 1

    G0 = dict()
    H = dict()
    sys = dict()
    tf = dict()

    G0['A'] = np.zeros([nv, nv])
    G0['B'] = np.zeros([nv, 1])
    G0['C'] = np.zeros(nv)
    G0['D'] = np.zeros(1)

    G0['A'][0, 0] = -par['beta']/par['Lambda']
    for i in range(1, nv):
        G0['A'][0, i] = par['beta_i'][i-1] / par['Lambda']
        G0['A'][i, 0] = par['lambda_i'][i-1]
        G0['A'][i, i] = -par['lambda_i'][i - 1]
    G0['B'][0] = 1 / par['Lambda']
    G0['C'][0] = 1

    H['A'] = np.zeros([3, 3])
    H['B'] = np.zeros([3, 1])
    H['C'] = dict()
    H['D'] = np.zeros(1)

    H['A'][0, 0] = -par['Kfm'] / (par['m_f'] * par['c_f'])
    H['A'][0, 1] = par['Kfm'] / (par['m_f'] * par['c_f'])
    H['A'][1, 0] = par['Kfm'] / (par['m_m'] * par['c_m'])
    H['A'][1, 1] = -(par['Kfm'] + par['Kmc']) / (par['m_m'] * par['c_m'])
    H['A'][1, 2] = par['Kmc'] / (par['m_m'] * par['c_m'])
    H['A'][2, 1] = par['Kmc'] / (par['m_c'] * par['c_c'])
    H['A'][2, 2] = -(par['Kmc'] + 2 * par['m_dot'] * par['c_c']) / (par['m_c'] * par['c_c'])
    H['B'][0] = par['q'] / (par['m_f'] * par['c_f'])
    H['B'][1] = (1 - par['q']) / (par['m_m'] * par['c_m'])
    H['C']['f'] = np.array([1, 0, 0])
    H['C']['m'] = np.array([0, 1, 0])
    H['C']['c'] = np.array([0, 0, 1])

    sys['G0'] = ct.ss(G0['A'], G0['B'], G0['C'], G0['D'])
    sys['H_f'] = ct.ss(H['A'], H['B'], H['C']['f'], H['D'])
    sys['H_m'] = ct.ss(H['A'], H['B'], H['C']['m'], H['D'])
    sys['H_c'] = ct.ss(H['A'], H['B'], H['C']['c'], H['D'])
    sys['H'] = par['P'] * (par['alpha_f'] * sys['H_f'] +
                           par['alpha_m'] * sys['H_m'])

    # defer TF transformation
    # tf['G0'] = ct.ss2tf(sys['G0'])
    # tf['H'] = ct.ss2tf(sys['H'])
    # tf['G'] = tf['G0'] / (1 - tf['G0'] * tf['H'])

    return sys

par = par_h()
sys_dict1 = sys_h(par)

par['lambda_i'][5] = 5
sys_dict2 = sys_h(par)

l1 = -sys_dict1['G0'] * sys_dict1['H']
l2 = -sys_dict2['G0'] * sys_dict2['H']

print(f'{ct.margin(l1)=}')
print(f'{ct.margin(l2)=}')

ct.bode([l1,l2])
plt.suptitle('SS bode')

## tf
l1_tf = ct.tf(l1)
l2_tf = ct.tf(l2)

# these are identical to above; this calc done on TF?
print(f'{ct.margin(l1_tf)=}')
print(f'{ct.margin(l2_tf)=}')

plt.figure()
ct.bode([l1_tf,l2_tf])
plt.suptitle('TF bode')

## explore stability margin of 0

## this pole is *JUST* in the RHP
print(f'{np.sort((l1_tf.pole().real))[-2:]=}')
## all zeros in the LHP
print(f'{max(l1_tf.zero().real)=}')

ks = np.geomspace(1,1e-10,11)
maxclpole = np.array([max(ct.feedback(k*l1_tf).pole().real)
                      for k in ks])

# check all closed loop stable
assert all(maxclpole < 0)

fig, ax = plt.subplots()
plt.loglog(ks, -maxclpole)
plt.xlabel('gain')
plt.ylabel(r'-max Re $\sigma$')
plt.grid()
plt.title('Stability versus gain')

plt.show()

@shaikinast
Copy link
Author

Thank you @roryyorke for the great explanation!

I understand that the problem appears when converting from SS to TF.

By the way, do you have any idea why it happens?

Also, I understand that margin transforms from SS to TF - is there a way to prevent this, such that if we use margin with SS we can omit the almost-zero-gain values?

@roryyorke
Copy link
Contributor

Sorry, I was wrong. I thought I was running python-control 0.9.2, but it was 0.9.1. When I ran the above in 0.9.2, I got the "expected" result:

image

and

image

and output:

ct.margin(l1)=(inf, 59.301802724179424, nan, 0.047580613320628885)
ct.margin(l2)=(inf, 59.32066927612266, nan, 0.04759166643038918)
ct.margin(l1_tf)=(inf, 59.301802724179424, nan, 0.047580613320628885)
ct.margin(l2_tf)=(inf, 59.32066927612266, nan, 0.04759166643038918)
np.sort((l1_tf.pole().real))[-2:]=array([-4.40194881e-03, -1.59265245e-10])
max(l1_tf.zero().real)=-0.00440194928446198

In this case the state-space form of L1 norm happens to have a RHP pole:

In [76]: print(f'{np.sort((l1.pole().real))[-2:] = }')
np.sort((l1.pole().real))[-2:] = array([-4.40194932e-03,  4.16545269e-14])

python-control 0.9.1 and 0.9.2 produce different (but equivalent) state space representations, and these differences, along with round-off error, give rise to these stability differences.

I think the difference in the figures above from the previous results is that the open-loop TFs ended up being open-loop stable with 0.9.2, so the Bode plot isn't extended all the way to 1e-14 rad/s.

Given the order of the other poles (magnitude 1e-3 to 1), the pole with magnitude 1e-14 is probably "really" an integrator, and if it isn't, on the time-scale of the other poles it might as well be. Numerical round-off means it can be nudged slightly left or right, ending up in the LHP or RHP. This doesn't really matter: it's close enough to 0 to "look like" an integrator.

Although the problem in this case wasn't due to switching to TF form, I still I suggest sticking with state-space form as long as possible; multiplied-out transfer functions are not good for high-order systems. Look up Wilkinson's polynomial to see why.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants