Skip to content

Commit aa92b65

Browse files
Disk margin calculations (#1146)
* Initial version of disk margin calculation and example/test script * Comment updates: update margins.py header, clarify import exception handler comment, fix typo in skew description of disk_margins docstring * More work in progress on disk margin calculation, adding new prototype function to plot allowable gain/phase variations. * Add disk_margin_plot to subroutine list in comment header in margins.py * Follow-on to ba15789, add disk_margin_plot to list of functions within the margins module * 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. * Further progress/debugging on disk margin calculation + plot utility * Clean up docstring/code for disk_margin_plot * Clean up docstring/code for disk_margin_plot * Remove debugging statements, update comments, add unit tests. * Minor change to fix logic to find minimum across DGM, DPM numpy vectors * Rename disk margin example, since unit tests are now written in control/tests/margin_test.py * Remove unneeded dependencies from margins.py, used for debugging * Minor updates to docstrings * Undo d92fb20 * Minor tweaks to plots in example script for readability * Fix typo in disk_margin_plot. * Fix mag2db import hack/workaround and trim down disk_margin docstring. * Add input handling to disk_margin, clean up column width/comments * Move disk_margin_plot out of the library into the example script * Recommended changes from the linter * Follow-on to 5f34a7b * Add disk_margins to function list * Whittle down the docstring from disk_margins * Put more comments in the disk margin example, add example to documentation * Fixing docstrings * Corrected expected values for 'no-slycot' condition in newly-added unit tests * Attempt #2 at 397efab, based on linter recommendation * Address @murrayrm review comments. * Update formatting per PEP8/@murrayrm review comments. Add additional reference on disk/ellipse-based margin calculations. * Follow-on to e8897f6: remove now-unnecessary import of importlib * Update formatting per @murrayrm review comments * Remove temporarily-added string from docstring * Minor tweak to docstring to fit the word 'function' back into the description of skew = 0.0
1 parent 7b06774 commit aa92b65

File tree

5 files changed

+814
-4
lines changed

5 files changed

+814
-4
lines changed

control/margins.py

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@
1111
import numpy as np
1212
import scipy as sp
1313

14-
from . import frdata, freqplot, xferfcn
14+
from . import frdata, freqplot, xferfcn, statesp
1515
from .exception import ControlMIMONotImplemented
1616
from .iosys import issiso
17+
from .ctrlutil import mag2db
18+
try:
19+
from slycot import ab13md
20+
except ImportError:
21+
ab13md = None
1722

18-
__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin']
19-
23+
__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin',
24+
'disk_margins']
2025

2126
# private helper functions
2227
def _poly_iw(sys):
@@ -168,6 +173,7 @@ def fun(wdt):
168173

169174
return z, w
170175

176+
171177
def _likely_numerical_inaccuracy(sys):
172178
# crude, conservative check for if
173179
# num(z)*num(1/z) << den(z)*den(1/z) for DT systems
@@ -517,3 +523,135 @@ def margin(*args):
517523
% len(args))
518524

519525
return margin[0], margin[1], margin[3], margin[4]
526+
527+
528+
def disk_margins(L, omega, skew=0.0, returnall=False):
529+
"""Disk-based stability margins of loop transfer function.
530+
531+
Parameters
532+
----------
533+
L : `StateSpace` or `TransferFunction`
534+
Linear SISO or MIMO loop transfer function.
535+
omega : sequence of array_like
536+
1D array of (non-negative) frequencies (rad/s) at which
537+
to evaluate the disk-based stability margins.
538+
skew : float or array_like, optional
539+
skew parameter(s) for disk margin (default = 0.0).
540+
skew = 0.0 "balanced" sensitivity function 0.5*(S - T).
541+
skew = 1.0 sensitivity function S.
542+
skew = -1.0 complementary sensitivity function T.
543+
returnall : bool, optional
544+
If True, return frequency-dependent margins.
545+
If False (default), return worst-case (minimum) margins.
546+
547+
Returns
548+
-------
549+
DM : float or array_like
550+
Disk margin.
551+
DGM : float or array_like
552+
Disk-based gain margin.
553+
DPM : float or array_like
554+
Disk-based phase margin.
555+
556+
Example
557+
--------
558+
>> omega = np.logspace(-1, 3, 1001)
559+
>> P = control.ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10],
560+
[-10, 1]], 0)
561+
>> K = control.ss([], [], [], [[1, -2], [0, 1]])
562+
>> L = P * K
563+
>> DM, DGM, DPM = control.disk_margins(L, omega, skew=0.0)
564+
"""
565+
566+
# First argument must be a system
567+
if not isinstance(L, (statesp.StateSpace, xferfcn.TransferFunction)):
568+
raise ValueError(
569+
"Loop gain must be state-space or transfer function object")
570+
571+
# Loop transfer function must be square
572+
if statesp.ss(L).B.shape[1] != statesp.ss(L).C.shape[0]:
573+
raise ValueError("Loop gain must be square (n_inputs = n_outputs)")
574+
575+
# Need slycot if L is MIMO, for mu calculation
576+
if not L.issiso() and ab13md == None:
577+
raise ControlMIMONotImplemented(
578+
"Need slycot to compute MIMO disk_margins")
579+
580+
# Get dimensions of feedback system
581+
num_loops = statesp.ss(L).C.shape[0]
582+
I = statesp.ss([], [], [], np.eye(num_loops))
583+
584+
# Loop sensitivity function
585+
S = I.feedback(L)
586+
587+
# Compute frequency response of the "balanced" (according
588+
# to the skew parameter "sigma") sensitivity function [1-2]
589+
ST = S + 0.5 * (skew - 1) * I
590+
ST_mag, ST_phase, _ = ST.frequency_response(omega)
591+
ST_jw = (ST_mag * np.exp(1j * ST_phase))
592+
if not L.issiso():
593+
ST_jw = ST_jw.transpose(2, 0, 1)
594+
595+
# Frequency-dependent complex disk margin, computed using
596+
# upper bound of the structured singular value, a.k.a. "mu",
597+
# of (S + (skew - I)/2).
598+
DM = np.zeros(omega.shape)
599+
DGM = np.zeros(omega.shape)
600+
DPM = np.zeros(omega.shape)
601+
for ii in range(0, len(omega)):
602+
# Disk margin (a.k.a. "alpha") vs. frequency
603+
if L.issiso() and ab13md == None:
604+
# For the SISO case, the norm on (S + (skew - I)/2) is
605+
# unstructured, and can be computed as the magnitude
606+
# of the frequency response.
607+
DM[ii] = 1.0 / ST_mag[ii]
608+
else:
609+
# For the MIMO case, the norm on (S + (skew - I)/2)
610+
# assumes a single complex uncertainty block diagonal
611+
# uncertainty structure. AB13MD provides an upper bound
612+
# on this norm at the given frequency omega[ii].
613+
DM[ii] = 1.0 / ab13md(ST_jw[ii], np.array(num_loops * [1]),
614+
np.array(num_loops * [2]))[0]
615+
616+
# Disk-based gain margin (dB) and phase margin (deg)
617+
with np.errstate(divide='ignore', invalid='ignore'):
618+
# Real-axis intercepts with the disk
619+
gamma_min = (1 - 0.5 * DM[ii] * (1 - skew)) \
620+
/ (1 + 0.5 * DM[ii] * (1 + skew))
621+
gamma_max = (1 + 0.5 * DM[ii] * (1 - skew)) \
622+
/ (1 - 0.5 * DM[ii] * (1 + skew))
623+
624+
# Gain margin (dB)
625+
DGM[ii] = mag2db(np.minimum(1 / gamma_min, gamma_max))
626+
if np.isnan(DGM[ii]):
627+
DGM[ii] = float('inf')
628+
629+
# Phase margin (deg)
630+
if np.isinf(gamma_max):
631+
DPM[ii] = 90.0
632+
else:
633+
DPM[ii] = (1 + gamma_min * gamma_max) \
634+
/ (gamma_min + gamma_max)
635+
if abs(DPM[ii]) >= 1.0:
636+
DPM[ii] = float('Inf')
637+
else:
638+
DPM[ii] = np.rad2deg(np.arccos(DPM[ii]))
639+
640+
if returnall:
641+
# Frequency-dependent disk margin, gain margin and phase margin
642+
return DM, DGM, DPM
643+
else:
644+
# Worst-case disk margin, gain margin and phase margin
645+
if DGM.shape[0] and not np.isinf(DGM).all():
646+
with np.errstate(all='ignore'):
647+
gmidx = np.where(DGM == np.min(DGM))
648+
else:
649+
gmidx = -1
650+
651+
if DPM.shape[0]:
652+
pmidx = np.where(DPM == np.min(DPM))
653+
654+
return (
655+
float('inf') if DM.shape[0] == 0 else np.amin(DM),
656+
float('inf') if gmidx == -1 else DGM[gmidx][0],
657+
float('inf') if DPM.shape[0] == 0 else DPM[pmidx][0])

control/tests/margin_test.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515
from control import ControlMIMONotImplemented, FrequencyResponseData, \
1616
StateSpace, TransferFunction, margin, phase_crossover_frequencies, \
17-
stability_margins
17+
stability_margins, disk_margins, tf, ss
18+
from control.exception import slycot_check
1819

1920
s = TransferFunction.s
2021

@@ -372,3 +373,106 @@ def test_stability_margins_discrete(cnum, cden, dt,
372373
else:
373374
out = stability_margins(tf)
374375
assert_allclose(out, ref, rtol=rtol)
376+
377+
def test_siso_disk_margin():
378+
# Frequencies of interest
379+
omega = np.logspace(-1, 2, 1001)
380+
381+
# Loop transfer function
382+
L = tf(25, [1, 10, 10, 10])
383+
384+
# Balanced (S - T) disk-based stability margins
385+
DM, DGM, DPM = disk_margins(L, omega, skew=0.0)
386+
assert_allclose([DM], [0.46], atol=0.1) # disk margin of 0.46
387+
assert_allclose([DGM], [4.05], atol=0.1) # disk-based gain margin of 4.05 dB
388+
assert_allclose([DPM], [25.8], atol=0.1) # disk-based phase margin of 25.8 deg
389+
390+
# For SISO systems, the S-based (S) disk margin should match the third output
391+
# of existing library "stability_margins", i.e., minimum distance from the
392+
# Nyquist plot to -1.
393+
_, _, SM = stability_margins(L)[:3]
394+
DM = disk_margins(L, omega, skew=1.0)[0]
395+
assert_allclose([DM], [SM], atol=0.01)
396+
397+
def test_mimo_disk_margin():
398+
# Frequencies of interest
399+
omega = np.logspace(-1, 3, 1001)
400+
401+
# Loop transfer gain
402+
P = ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10], [-10, 1]], 0) # plant
403+
K = ss([], [], [], [[1, -2], [0, 1]]) # controller
404+
Lo = P * K # loop transfer function, broken at plant output
405+
Li = K * P # loop transfer function, broken at plant input
406+
407+
if slycot_check():
408+
# Balanced (S - T) disk-based stability margins at plant output
409+
DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0)
410+
assert_allclose([DMo], [0.3754], atol=0.1) # disk margin of 0.3754
411+
assert_allclose([DGMo], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB
412+
assert_allclose([DPMo], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg
413+
414+
# Balanced (S - T) disk-based stability margins at plant input
415+
DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0)
416+
assert_allclose([DMi], [0.3754], atol=0.1) # disk margin of 0.3754
417+
assert_allclose([DGMi], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB
418+
assert_allclose([DPMi], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg
419+
else:
420+
# Slycot not installed. Should throw exception.
421+
with pytest.raises(ControlMIMONotImplemented,\
422+
match="Need slycot to compute MIMO disk_margins"):
423+
DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0)
424+
425+
def test_siso_disk_margin_return_all():
426+
# Frequencies of interest
427+
omega = np.logspace(-1, 2, 1001)
428+
429+
# Loop transfer function
430+
L = tf(25, [1, 10, 10, 10])
431+
432+
# Balanced (S - T) disk-based stability margins
433+
DM, DGM, DPM = disk_margins(L, omega, skew=0.0, returnall=True)
434+
assert_allclose([omega[np.argmin(DM)]], [1.94],\
435+
atol=0.01) # sensitivity peak at 1.94 rad/s
436+
assert_allclose([min(DM)], [0.46], atol=0.1) # disk margin of 0.46
437+
assert_allclose([DGM[np.argmin(DM)]], [4.05],\
438+
atol=0.1) # disk-based gain margin of 4.05 dB
439+
assert_allclose([DPM[np.argmin(DM)]], [25.8],\
440+
atol=0.1) # disk-based phase margin of 25.8 deg
441+
442+
def test_mimo_disk_margin_return_all():
443+
# Frequencies of interest
444+
omega = np.logspace(-1, 3, 1001)
445+
446+
# Loop transfer gain
447+
P = ss([[0, 10], [-10, 0]], np.eye(2),\
448+
[[1, 10], [-10, 1]], 0) # plant
449+
K = ss([], [], [], [[1, -2], [0, 1]]) # controller
450+
Lo = P * K # loop transfer function, broken at plant output
451+
Li = K * P # loop transfer function, broken at plant input
452+
453+
if slycot_check():
454+
# Balanced (S - T) disk-based stability margins at plant output
455+
DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True)
456+
assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\
457+
atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided)
458+
assert_allclose([min(DMo)], [0.3754], atol=0.1) # disk margin of 0.3754
459+
assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\
460+
atol=0.1) # disk-based gain margin of 3.3 dB
461+
assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\
462+
atol=0.1) # disk-based phase margin of 21.26 deg
463+
464+
# Balanced (S - T) disk-based stability margins at plant input
465+
DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0, returnall=True)
466+
assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\
467+
atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided)
468+
assert_allclose([min(DMi)], [0.3754],\
469+
atol=0.1) # disk margin of 0.3754
470+
assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\
471+
atol=0.1) # disk-based gain margin of 3.3 dB
472+
assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\
473+
atol=0.1) # disk-based phase margin of 21.26 deg
474+
else:
475+
# Slycot not installed. Should throw exception.
476+
with pytest.raises(ControlMIMONotImplemented,\
477+
match="Need slycot to compute MIMO disk_margins"):
478+
DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True)

doc/examples/disk_margins.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Disk margin example
2+
------------------------------------------
3+
4+
This example demonstrates the use of the `disk_margins` routine
5+
to compute robust stability margins for a feedback system, i.e.,
6+
variation in gain and phase one or more loops. The SISO examples
7+
are drawn from the published paper and the MIMO example is the
8+
"spinning satellite" example from the MathWorks documentation.
9+
10+
Code
11+
....
12+
.. literalinclude:: disk_margins.py
13+
:language: python
14+
:linenos:
15+
16+
Notes
17+
.....
18+
1. The environment variable `PYCONTROL_TEST_EXAMPLES` is used for
19+
testing to turn off plotting of the outputs.

doc/functions.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Frequency domain analysis:
142142

143143
bandwidth
144144
dcgain
145+
disk_margins
145146
linfnorm
146147
margin
147148
stability_margins

0 commit comments

Comments
 (0)