• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

python-control / python-control / 16084012479

05 Jul 2025 03:18AM UTC coverage: 94.733%. Remained the same
16084012479

Pull #1164

github

web-flow
Merge fa15e6f08 into 03ae372c1
Pull Request #1164: small fixes for 0.10.2 release

9946 of 10499 relevant lines covered (94.73%)

8.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.31
control/margins.py
1
# margins.py - functions for computing stability margins
2
#
3
# Initial author: Richard M. Murray
4
# Creation date: 14 July 2011
5

6
"""Functions for computing stability margins and related functions."""
7

8
import math
9✔
9
from warnings import warn
9✔
10

11
import numpy as np
9✔
12
import scipy as sp
9✔
13

14
from . import frdata, freqplot, xferfcn, statesp
9✔
15
from .exception import ControlMIMONotImplemented
9✔
16
from .iosys import issiso
9✔
17
from .ctrlutil import mag2db
9✔
18
try:
9✔
19
    from slycot import ab13md
9✔
20
except ImportError:
4✔
21
    ab13md = None
4✔
22

23
__all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin',
9✔
24
           'disk_margins']
25

26
# private helper functions
27
def _poly_iw(sys):
9✔
28
    """Apply s = iw to G(s)=num(s)/den(s)
29

30
    Splits the num and den polynomials with (iw) applied into real and
31
    imaginary parts with w applied
32
    """
33
    num = sys.num[0][0]
9✔
34
    den = sys.den[0][0]
9✔
35
    num_iw = (1J)**np.arange(len(num) - 1, -1, -1) * num
9✔
36
    den_iw = (1J)**np.arange(len(den) - 1, -1, -1) * den
9✔
37
    return num_iw, den_iw
9✔
38

39

40
def _poly_iw_sqr(pol_iw):
9✔
41
    return np.real(np.polymul(pol_iw, pol_iw.conj()))
9✔
42

43

44
def _poly_iw_real_crossing(num_iw, den_iw, epsw):
9✔
45
    # Return w where imag(H(iw)) == 0
46

47
    # Compute the imaginary part of H = (num.r + j num.i)/(den.r + j den.i)
48
    test_w = np.polysub(np.polymul(num_iw.imag, den_iw.real),
9✔
49
                        np.polymul(num_iw.real, den_iw.imag))
50

51
    # Find the real-valued w > 0 where imag(H(iw)) = 0
52
    w = np.roots(test_w)
9✔
53
    w = np.real(w[np.isreal(w)])
9✔
54
    w = w[w >= epsw]
9✔
55

56
    return w
9✔
57

58

59
def _poly_iw_mag1_crossing(num_iw, den_iw, epsw):
9✔
60
    # Return w where |H(iw)| == 1, |num(iw)| - |den(iw)| == 0
61
    w = np.roots(np.polysub(_poly_iw_sqr(num_iw), _poly_iw_sqr(den_iw)))
9✔
62
    w = np.real(w[np.isreal(w)])
9✔
63
    w = w[w > epsw]
9✔
64
    return w
9✔
65

66

67
def _poly_iw_wstab(num_iw, den_iw, epsw):
9✔
68
    # Stability margin: minimum distance to point -1
69
    # find zero derivative. Second derivative needs to be >0
70
    # to have a minimum
71
    test_wstabn = _poly_iw_sqr(np.polyadd(num_iw, den_iw))
9✔
72
    test_wstabd = _poly_iw_sqr(den_iw)
9✔
73
    test_wstab = np.polysub(
9✔
74
        np.polymul(np.polyder(test_wstabn), test_wstabd),
75
        np.polymul(np.polyder(test_wstabd), test_wstabn))
76

77
    # find the solutions, for positive omega, and only real ones
78
    wstab = np.roots(test_wstab)
9✔
79
    wstab = np.real(wstab[np.isreal(wstab)])
9✔
80
    wstab = wstab[wstab > epsw]
9✔
81

82
    # and find the value of the 2nd derivative there, needs to be positive
83
    wstabplus = np.polyval(np.polyder(test_wstab), wstab)
9✔
84
    wstab = wstab[wstabplus > 0.]
9✔
85
    return wstab
9✔
86

87

88
def _poly_z_invz(sys):
9✔
89
    num = sys.num[0][0]  # num(z) = a_p * z^p + a_(p-1) * z^(p-1) + ... + a_0
9✔
90
    den = sys.den[0][0]  # num(z) = b_q * z^p + b_(q-1) * z^(q-1) + ... + b_0
9✔
91
    p_q = len(num) - len(den)
9✔
92
    if p_q > 0:
9✔
93
        raise ValueError("Not a proper transfer function: Denominator must "
×
94
                         "have equal or higher order than numerator.")
95
    num_inv_zp = num[::-1]  # num(1/z) * z^p
9✔
96
    den_inv_zq = den[::-1]  # den(1/z) * z^q
9✔
97
    return num, den, num_inv_zp, den_inv_zq, p_q, sys.dt
9✔
98

99

100
def _z_filter(z, dt, eps):
9✔
101
    # z = exp(1J w dt)
102
    # |z| == 1 with some float precision tolerance
103
    z = z[np.abs(np.abs(z) - 1.) < eps]
9✔
104
    zarg = np.angle(z)
9✔
105
    zidx = (0 <= zarg) * (zarg < np.pi)
9✔
106
    omega = zarg[zidx] / dt
9✔
107
    return z[zidx], omega
9✔
108

109

110
def _poly_z_real_crossing(num, den, num_inv_zp, den_inv_zq, p_q, dt, epsw):
9✔
111
    # H(z)==H(1/z), num(z)*den(1/z) == num(1/z)*den(z)
112
    p1 = np.polymul(num, den_inv_zq)
9✔
113
    p2 = np.polymul(num_inv_zp, den)
9✔
114
    if p_q < 0:
9✔
115
        # * z**(-p_q)
116
        x = [1] + [0] * (-p_q)
9✔
117
        p2 = np.polymul(p2, x)
9✔
118
    z = np.roots(np.polysub(p1, p2))
9✔
119
    eps = np.finfo(float).eps**(1 / len(p2))
9✔
120
    z, w = _z_filter(z, dt, eps)
9✔
121
    z = z[w >= epsw]
9✔
122
    w = w[w >= epsw]
9✔
123
    return z, w
9✔
124

125

126
def _poly_z_mag1_crossing(num, den, num_inv_zp, den_inv_zq, p_q, dt, epsw):
9✔
127
    # |H(z)| = 1, H(z)*H(1/z)=1, num(z)*num(1/z) == den(z)*den(1/z)
128
    p1 = np.polymul(num, num_inv_zp)
9✔
129
    p2 = np.polymul(den, den_inv_zq)
9✔
130
    if p_q < 0:
9✔
131
        # * z**(-p_q)
132
        x = [1] + [0] * (-p_q)
9✔
133
        p1 = np.polymul(p1, x)
9✔
134
    z = np.roots(np.polysub(p1, p2))
9✔
135
    eps = np.finfo(float).eps**(1 / len(p2))
9✔
136
    z, w = _z_filter(z, dt, eps)
9✔
137
    z = z[w > epsw]
9✔
138
    w = w[w > epsw]
9✔
139
    return z, w
9✔
140

141

142
def _poly_z_wstab(num, den, num_inv_zp, den_inv_zq, p_q, dt, epsw):
9✔
143
    # Stability margin: Minimum distance to -1
144

145
    # TODO: Find a way to solve for z or omega analytically with given
146
    # polynomials
147
    # d|1 + H(z)|/dz = 0, or d|1 + H(exp(iwdt))|/dw = 0
148

149
    # optimization function to minimize
150
    def fun(wdt):
9✔
151
        with np.errstate(all='ignore'):  # den=0 is okay
9✔
152
            return np.abs(1 + (np.polyval(num, np.exp(1J * wdt)) /
9✔
153
                               np.polyval(den, np.exp(1J * wdt))))
154

155
    # find initial guess
156
    wdt_v = np.geomspace(1e-4, 2 * np.pi, num=100)
9✔
157
    wdt0 = wdt_v[np.argmin(fun(wdt_v))]
9✔
158

159
    # Use `minimize` instead of univariate `minimize_scalars` because we want
160
    # to provide some initial value in order to not converge on frequencies
161
    # with extremely low gradients.
162
    res = sp.optimize.minimize(
9✔
163
        fun=fun,
164
        x0=[wdt0],
165
        bounds=[(0, 2 * np.pi)])
166
    if res.success:
9✔
167
        wdt = res.x
9✔
168
        z = np.exp(1J * wdt)
9✔
169
        w = wdt / dt
9✔
170
    else:
171
        z = np.array([])
×
172
        w = np.array([])
×
173

174
    return z, w
9✔
175

176

177
def _likely_numerical_inaccuracy(sys):
9✔
178
    # crude, conservative check for if
179
    # num(z)*num(1/z) << den(z)*den(1/z) for DT systems
180
    num, den, num_inv_zp, den_inv_zq, p_q, dt = _poly_z_invz(sys)
9✔
181
    p1 = np.polymul(num, num_inv_zp)
9✔
182
    p2 = np.polymul(den, den_inv_zq)
9✔
183
    if p_q < 0:
9✔
184
        # * z**(-p_q)
185
        x = [1] + [0] * (-p_q)
9✔
186
        p1 = np.polymul(p1, x)
9✔
187
    return np.linalg.norm(p1) < 1e-4 * np.linalg.norm(p2)
9✔
188

189
# Took the framework for the old function by
190
# Sawyer B. Fuller <minster@uw.edu>, removed a lot of the innards
191
# and replaced with analytical polynomial functions for LTI systems.
192
#
193
# The idea for the frequency data solution copied/adapted from
194
# https://github.com/alchemyst/Skogestad-Python/blob/master/BODE.py
195
# Rene van Paassen <rene.vanpaassen@gmail.com>
196
#
197
# RvP, July 8, 2014, corrected to exclude phase=0 crossing for the gain
198
#                    margin polynomial
199
#
200
# RvP, July 8, 2015, augmented to calculate all phase/gain crossings with
201
#                    frd data. Correct to return smallest phase
202
#                    margin, smallest gain margin and their frequencies
203
#
204
# RvP, Jun 10, 2017, modified the inclusion of roots found for phase crossing
205
#                    to include all >= 0, made subsequent calc insensitive to
206
#                    div by 0.  Also changed the selection of which crossings
207
#                    to return on basis of "A note on the Gain and Phase
208
#                    Margin Concepts" Journal of Control and Systems
209
#                    Engineering, Yazdan Bavafi-Toosi, Dec 2015, vol 3 issue
210
#                    1, pp 51-59, closer to Matlab behavior, but not
211
#                    completely identical in edge cases, which don't cross but
212
#                    touch gain=1.
213
#
214
# BG, Nov 9, 2020,   removed duplicate implementations of the same code
215
#                    for crossover frequencies and enhanced to handle discrete
216
#                    systems
217

218

219
# TODO: consider handling sysdata similar to margin (via *sysdata?)
220
def stability_margins(sysdata, returnall=False, epsw=0.0, method='best'):
9✔
221
    """Stability margins and associated crossover frequencies.
222

223
    Parameters
224
    ----------
225
    sysdata : LTI system or 3-tuple of array_like
226
        Linear SISO system representing the loop transfer function.
227
        Alternatively, a three tuple of the form (mag, phase, omega)
228
        providing the frequency response can be passed.
229
    returnall : bool, optional
230
        If true, return all margins found. If False (default), return only the
231
        minimum stability margins. For frequency data or FRD systems, only
232
        margins in the given frequency region can be found and returned.
233
    epsw : float, optional
234
        Frequencies below this value (default 0.0) are considered static gain,
235
        and not returned as margin.
236
    method : string, optional
237
        Method to use (default is 'best'):
238

239
        * 'poly': use polynomial method if passed a `LTI` system.
240
        * 'frd': calculate crossover frequencies using numerical
241
          interpolation of a `FrequencyResponseData` representation
242
          of the system if passed a `LTI` system.
243
        * 'best': use the 'poly' method if possible, reverting to 'frd' if
244
          it is detected that numerical inaccuracy is likely to arise in the
245
          'poly' method for for discrete-time systems.
246

247
    Returns
248
    -------
249
    gm : float or array_like
250
        Gain margin.
251
    pm : float or array_like
252
        Phase margin.
253
    sm : float or array_like
254
        Stability margin, the minimum distance from the Nyquist plot to -1.
255
    wpc : float or array_like
256
        Phase crossover frequency (where phase crosses -180 degrees), which is
257
        associated with the gain margin.
258
    wgc : float or array_like
259
        Gain crossover frequency (where gain crosses 1), which is associated
260
        with the phase margin.
261
    wms : float or array_like
262
        Stability margin frequency (where Nyquist plot is closest to -1).
263

264
    Notes
265
    -----
266
    The gain margin is determined by the gain of the loop transfer function
267
    at the phase crossover frequency(s), the phase margin is determined by
268
    the phase of the loop transfer function at the gain crossover
269
    frequency(s), and the stability margin is determined by the frequency
270
    of maximum sensitivity (given by the magnitude of 1/(1+L)).
271

272
    """
273
    # TODO: FRD method for cont-time systems doesn't work
274
    try:
9✔
275
        if isinstance(sysdata, frdata.FRD):
9✔
276
            sys = frdata.FRD(sysdata, smooth=True)
9✔
277
        elif isinstance(sysdata, xferfcn.TransferFunction):
9✔
278
            sys = sysdata
9✔
279
        elif getattr(sysdata, '__iter__', False) and len(sysdata) == 3:
9✔
280
            mag, phase, omega = sysdata
9✔
281
            sys = frdata.FRD(mag * np.exp(1j * phase * math.pi / 180.),
9✔
282
                             omega, smooth=True)
283
        else:
284
            sys = xferfcn._convert_to_transfer_function(sysdata)
9✔
285
    except Exception as e:
×
286
        print(e)
×
287
        raise ValueError("Margin sysdata must be either a linear system or "
×
288
                         "a 3-sequence of mag, phase, omega.")
289

290
    # check for siso
291
    if not issiso(sys):
9✔
292
        raise ControlMIMONotImplemented(
×
293
            "Can only do margins for SISO system")
294

295
    if method == 'frd':
9✔
296
        # convert to FRD if we got a transfer function
297
        if isinstance(sys, xferfcn.TransferFunction):
9✔
298
            omega_sys = freqplot._default_frequency_range(sys)
9✔
299
            if sys.isctime():
9✔
300
                sys = frdata.FRD(sys, omega_sys)
×
301
            else:
302
                omega_sys = omega_sys[omega_sys < np.pi / sys.dt]
9✔
303
                sys = frdata.FRD(sys, omega_sys, smooth=True)
9✔
304
    elif method == 'best':
9✔
305
        # convert to FRD if anticipated numerical issues
306
        if isinstance(sys, xferfcn.TransferFunction) and not sys.isctime():
9✔
307
            if _likely_numerical_inaccuracy(sys):
9✔
308
                warn("stability_margins: Falling back to 'frd' method "
9✔
309
                "because of chance of numerical inaccuracy in 'poly' method.",
310
                stacklevel=2)
311
                omega_sys = freqplot._default_frequency_range(sys)
9✔
312
                omega_sys = omega_sys[omega_sys < np.pi / sys.dt]
9✔
313
                sys = frdata.FRD(sys, omega_sys, smooth=True)
9✔
314
    elif method != 'poly':
×
315
        raise ValueError("method " + method + " unknown")
×
316

317
    if isinstance(sys, xferfcn.TransferFunction):
9✔
318
        if sys.isctime():
9✔
319
            num_iw, den_iw = _poly_iw(sys)
9✔
320
            # frequency for gain margin: phase crosses -180 degrees
321
            w_180 = _poly_iw_real_crossing(num_iw, den_iw, epsw)
9✔
322
            w180_resp = sys(1J * w_180, warn_infinite=False)  # den=0 is okay
9✔
323

324
            # frequency for phase margin : gain crosses magnitude 1
325
            wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw)
9✔
326
            wc_resp = sys(1J * wc)
9✔
327

328
            # stability margin
329
            wstab = _poly_iw_wstab(num_iw, den_iw, epsw)
9✔
330
            ws_resp = sys(1J * wstab)
9✔
331

332
        else:  # Discrete Time
333
            zargs = _poly_z_invz(sys)
9✔
334
            # gain margin
335
            z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw)
9✔
336
            w180_resp = sys(z)
9✔
337

338
            # phase margin
339
            z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw)
9✔
340
            wc_resp = sys(z)
9✔
341

342
            # stability margin
343
            z, wstab = _poly_z_wstab(*zargs, epsw=epsw)
9✔
344
            ws_resp = sys(z)
9✔
345

346
        # only keep frequencies where the negative real axis is crossed
347
        w_180 = w_180[w180_resp <= 0.]
9✔
348
        w180_resp = w180_resp[w180_resp <= 0.]
9✔
349

350
        # sort
351
        idx = np.argsort(w_180)
9✔
352
        w_180 = w_180[idx]
9✔
353
        w180_resp = w180_resp[idx]
9✔
354

355
        idx = np.argsort(wc)
9✔
356
        wc = wc[idx]
9✔
357
        wc_resp = wc_resp[idx]
9✔
358

359
        idx = np.argsort(wstab)
9✔
360
        wstab = wstab[idx]
9✔
361
        ws_resp = ws_resp[idx]
9✔
362

363
    else:
364
        # a bit coarse, have the interpolated frd evaluated again
365
        def _mod(w):
9✔
366
            """Calculate |G(jw)| - 1"""
367
            return np.abs(sys(1j * w)) - 1
9✔
368

369
        def _arg(w):
9✔
370
            """Calculate the phase angle at -180 deg"""
371
            return np.angle(-sys(1j * w))
9✔
372

373
        def _dstab(w):
9✔
374
            """Calculate the distance from -1 point"""
375
            return np.abs(sys(1j * w) + 1.)
9✔
376

377
        # find the phase crossings ang(H(jw) == -180
378
        widx = np.where(np.diff(np.sign(_arg(sys.omega))))[0]
9✔
379
        widx = widx[np.real(sys(1j * sys.omega[widx])) <= 0]
9✔
380
        w_180 = np.array(
9✔
381
            [sp.optimize.brentq(_arg, sys.omega[i], sys.omega[i+1])
382
             for i in widx])
383
        w180_resp = sys(1j * w_180)
9✔
384

385
        # Find all crossings, note that this depends on omega having
386
        # a correct range
387
        widx = np.where(np.diff(np.sign(_mod(sys.omega))))[0]
9✔
388
        wc = np.array(
9✔
389
            [sp.optimize.brentq(_mod, sys.omega[i], sys.omega[i+1])
390
             for i in widx])
391
        wc_resp = sys(1j * wc)
9✔
392

393
        # find all stab margins?
394
        widx, = np.where(np.diff(np.sign(np.diff(_dstab(sys.omega)))) > 0)
9✔
395
        wstab = np.array(
9✔
396
            [sp.optimize.minimize_scalar(
397
                _dstab, bracket=(sys.omega[i], sys.omega[i+1])).x
398
             for i in widx])
399
        wstab = wstab[(wstab >= sys.omega[0]) * (wstab <= sys.omega[-1])]
9✔
400
        ws_resp = sys(1j * wstab)
9✔
401

402
    with np.errstate(all='ignore'):  # |G|=0 is okay and yields inf
9✔
403
        GM = 1. / np.abs(w180_resp)
9✔
404
    PM = np.remainder(np.angle(wc_resp, deg=True), 360.) - 180.
9✔
405
    SM = np.abs(ws_resp + 1.)
9✔
406

407
    if returnall:
9✔
408
        return GM, PM, SM, w_180, wc, wstab
9✔
409
    else:
410
        if GM.shape[0] and not np.isinf(GM).all():
9✔
411
            with np.errstate(all='ignore'):
9✔
412
                gmidx = np.where(np.abs(np.log(GM)) ==
9✔
413
                                 np.min(np.abs(np.log(GM))))
414
        else:
415
            gmidx = -1
9✔
416
        if PM.shape[0]:
9✔
417
            pmidx = np.where(np.abs(PM) == np.amin(np.abs(PM)))[0]
9✔
418
        return (
9✔
419
            (not gmidx != -1 and float('inf')) or GM[gmidx][0],
420
            (not PM.shape[0] and float('inf')) or PM[pmidx][0],
421
            (not SM.shape[0] and float('inf')) or np.amin(SM),
422
            (not gmidx != -1 and float('nan')) or w_180[gmidx][0],
423
            (not wc.shape[0] and float('nan')) or wc[pmidx][0],
424
            (not wstab.shape[0] and float('nan')) or
425
            wstab[SM == np.amin(SM)][0])
426

427

428
# Contributed by Steffen Waldherr <waldherr@ist.uni-stuttgart.de>
429
def phase_crossover_frequencies(sys):
9✔
430
    """Compute Nyquist plot real-axis crossover frequencies and gains.
431

432
    Parameters
433
    ----------
434
    sys : LTI
435
        SISO LTI system.
436

437
    Returns
438
    -------
439
    omega : ndarray
440
        1d array of (non-negative) frequencies where Nyquist plot
441
        intersects the real axis.
442
    gains : ndarray
443
        1d array of corresponding gains.
444

445
    Examples
446
    --------
447
    >>> G = ct.tf([1], [1, 2, 3, 4])
448
    >>> x_omega, x_gain = ct.phase_crossover_frequencies(G)
449

450
    """
451
    # Convert to a transfer function
452
    tf = xferfcn._convert_to_transfer_function(sys)
9✔
453

454
    if not issiso(tf):
9✔
455
        raise ControlMIMONotImplemented(
9✔
456
            "Can only calculate crossovers for SISO system")
457

458
    # Compute frequencies that we cross over the real axis
459
    if sys.isctime():
9✔
460
        num_iw, den_iw = _poly_iw(tf)
9✔
461
        omega = _poly_iw_real_crossing(num_iw, den_iw, 0.)
9✔
462

463
        # using real() to avoid rounding errors and results like 1+0j
464
        gains = np.real(sys(omega * 1j, warn_infinite=False))
9✔
465
    else:
466
        zargs = _poly_z_invz(sys)
9✔
467
        z, omega = _poly_z_real_crossing(*zargs, epsw=0.)
9✔
468
        gains = np.real(sys(z, warn_infinite=False))
9✔
469

470
    return omega, gains
9✔
471

472

473
def margin(*args):
9✔
474
    """
475
    margin(sys) \
476
    margin(mag, phase, omega)
477

478
    Gain and phase margins and associated crossover frequencies.
479

480
    Can be called as ``margin(sys)`` where `sys` is a SISO LTI system or
481
    ``margin(mag, phase, omega)``.
482

483
    Parameters
484
    ----------
485
    sys : `StateSpace` or `TransferFunction`
486
        Linear SISO system representing the loop transfer function.
487
    mag, phase, omega : sequence of array_like
488
        Input magnitude, phase (in deg.), and frequencies (rad/sec) from
489
        bode frequency response data.
490

491
    Returns
492
    -------
493
    gm : float
494
        Gain margin.
495
    pm : float
496
        Phase margin (in degrees).
497
    wcg : float or array_like
498
        Crossover frequency associated with gain margin (phase crossover
499
        frequency), where phase crosses below -180 degrees.
500
    wcp : float or array_like
501
        Crossover frequency associated with phase margin (gain crossover
502
        frequency), where gain crosses below 1.
503

504
    Margins are calculated for a SISO open-loop system.
505

506
    If there is more than one gain crossover, the one at the smallest margin
507
    (deviation from gain = 1), in absolute sense, is returned. Likewise the
508
    smallest phase margin (in absolute sense) is returned.
509

510
    Examples
511
    --------
512
    >>> G = ct.tf(1, [1, 2, 1, 0])
513
    >>> gm, pm, wcg, wcp = ct.margin(G)
514

515
    """
516
    if len(args) == 1:
9✔
517
        sys = args[0]
9✔
518
        margin = stability_margins(sys)
9✔
519
    elif len(args) == 3:
×
520
        margin = stability_margins(args)
×
521
    else:
522
        raise ValueError("Margin needs 1 or 3 arguments; received %i."
×
523
                         % len(args))
524

525
    return margin[0], margin[1], margin[3], margin[4]
9✔
526

527

528
def disk_margins(L, omega, skew=0.0, returnall=False):
9✔
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

541
        * skew = 0.0 "balanced" sensitivity function 0.5*(S - T)
542
        * skew = 1.0 sensitivity function S
543
        * skew = -1.0 complementary sensitivity function T
544

545
    returnall : bool, optional
546
        If True, return frequency-dependent margins.
547
        If False (default), return worst-case (minimum) margins.
548

549
    Returns
550
    -------
551
    DM : float or array_like
552
        Disk margin.
553
    DGM : float or array_like
554
        Disk-based gain margin.
555
    DPM : float or array_like
556
        Disk-based phase margin.
557

558
    Examples
559
    --------
560
    >> omega = np.logspace(-1, 3, 1001)
561
    >> P = control.ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10],
562
    [-10, 1]], 0)
563
    >> K = control.ss([], [], [], [[1, -2], [0, 1]])
564
    >> L = P * K
565
    >> DM, DGM, DPM = control.disk_margins(L, omega, skew=0.0)
566
    """
567

568
    # First argument must be a system
569
    if not isinstance(L, (statesp.StateSpace, xferfcn.TransferFunction)):
9✔
570
        raise ValueError(
×
571
            "Loop gain must be state-space or transfer function object")
572

573
    # Loop transfer function must be square
574
    if statesp.ss(L).B.shape[1] != statesp.ss(L).C.shape[0]:
9✔
575
        raise ValueError("Loop gain must be square (n_inputs = n_outputs)")
×
576

577
    # Need slycot if L is MIMO, for mu calculation
578
    if not L.issiso() and ab13md == None:
9✔
579
        raise ControlMIMONotImplemented(
4✔
580
            "Need slycot to compute MIMO disk_margins")
581

582
    # Get dimensions of feedback system
583
    num_loops = statesp.ss(L).C.shape[0]
9✔
584
    I = statesp.ss([], [], [], np.eye(num_loops))
9✔
585

586
    # Loop sensitivity function
587
    S = I.feedback(L)
9✔
588

589
    # Compute frequency response of the "balanced" (according
590
    # to the skew parameter "sigma") sensitivity function [1-2]
591
    ST = S + 0.5 * (skew - 1) * I
9✔
592
    ST_mag, ST_phase, _ = ST.frequency_response(omega)
9✔
593
    ST_jw = (ST_mag * np.exp(1j * ST_phase))
9✔
594
    if not L.issiso():
9✔
595
        ST_jw = ST_jw.transpose(2, 0, 1)
5✔
596

597
    # Frequency-dependent complex disk margin, computed using
598
    # upper bound of the structured singular value, a.k.a. "mu",
599
    # of (S + (skew - I)/2).
600
    DM = np.zeros(omega.shape)
9✔
601
    DGM = np.zeros(omega.shape)
9✔
602
    DPM = np.zeros(omega.shape)
9✔
603
    for ii in range(0, len(omega)):
9✔
604
        # Disk margin (a.k.a. "alpha") vs. frequency
605
        if L.issiso() and ab13md == None:
9✔
606
            # For the SISO case, the norm on (S + (skew - I)/2) is
607
            # unstructured, and can be computed as the magnitude
608
            # of the frequency response.
609
            DM[ii] = 1.0 / ST_mag[ii]
4✔
610
        else:
611
            # For the MIMO case, the norm on (S + (skew - I)/2)
612
            # assumes a single complex uncertainty block diagonal
613
            # uncertainty structure. AB13MD provides an upper bound
614
            # on this norm at the given frequency omega[ii].
615
            DM[ii] = 1.0 / ab13md(ST_jw[ii], np.array(num_loops * [1]),
5✔
616
                                  np.array(num_loops * [2]))[0]
617

618
        # Disk-based gain margin (dB) and phase margin (deg)
619
        with np.errstate(divide='ignore', invalid='ignore'):
9✔
620
            # Real-axis intercepts with the disk
621
            gamma_min = (1 - 0.5 * DM[ii] * (1 - skew)) \
9✔
622
                      / (1 + 0.5 * DM[ii] * (1 + skew))
623
            gamma_max = (1 + 0.5 * DM[ii] * (1 - skew)) \
9✔
624
                      / (1 - 0.5 * DM[ii] * (1 + skew))
625

626
            # Gain margin (dB)
627
            DGM[ii] = mag2db(np.minimum(1 / gamma_min, gamma_max))
9✔
628
            if np.isnan(DGM[ii]):
9✔
629
                DGM[ii] = float('inf')
9✔
630

631
            # Phase margin (deg)
632
            if np.isinf(gamma_max):
9✔
633
                DPM[ii] = 90.0
×
634
            else:
635
                DPM[ii] = (1 + gamma_min * gamma_max) \
9✔
636
                        / (gamma_min + gamma_max)
637
                if abs(DPM[ii]) >= 1.0:
9✔
638
                    DPM[ii] = float('Inf')
9✔
639
                else:
640
                    DPM[ii] = np.rad2deg(np.arccos(DPM[ii]))
9✔
641

642
    if returnall:
9✔
643
        # Frequency-dependent disk margin, gain margin and phase margin
644
        return DM, DGM, DPM
9✔
645
    else:
646
        # Worst-case disk margin, gain margin and phase margin
647
        if DGM.shape[0] and not np.isinf(DGM).all():
9✔
648
            with np.errstate(all='ignore'):
9✔
649
                gmidx = np.where(DGM == np.min(DGM))
9✔
650
        else:
651
            gmidx = -1
×
652

653
        if DPM.shape[0]:
9✔
654
            pmidx = np.where(DPM == np.min(DPM))
9✔
655

656
        return (
9✔
657
            float('inf') if DM.shape[0] == 0 else np.amin(DM),
658
            float('inf') if gmidx == -1 else DGM[gmidx][0],
659
            float('inf') if DPM.shape[0] == 0 else DPM[pmidx][0])
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc