Nyquist Plot, Root Locus, and Stability Margins in Python: Evaluating Closed-Loop Stability with Gain and Phase Margins

Assess control-system stability with Nyquist plots, root locus, and gain/phase margins — from transfer-function fundamentals to a Python implementation using scipy.signal.freqresp and numpy.roots. Using L(s)=K/(s(s+1)(s+2)), we confirm a phase margin of 53.4 deg, a gain margin of 15.6 dB, and a stability-limit gain of K=6 numerically.

Introduction

The Bode plot lets us read a frequency response (magnitude and phase) on a logarithmic scale. But to answer the central design questions — “Is this feedback loop stable?” and “How far can I raise the gain?” — we need a framework that infers closed-loop stability from open-loop characteristics.

This article covers the three pillars of that framework — the Nyquist plot, the root locus, and stability margins (gain margin and phase margin) — from transfer-function fundamentals to a Python implementation. As a running example we use the textbook open-loop transfer function

\[ L(s) = \frac{K}{s(s+1)(s+2)} \]

and confirm the phase margin, gain margin, and stability-limit gain numerically, using only scipy.signal and numpy (no control package required).

Feedback Systems and Closed-Loop Stability

For an open-loop transfer function \(L(s) = C(s)P(s)\) (controller \(C\) in series with plant \(P\) ) under unity negative feedback, the closed-loop transfer function is

\[ T(s) = \frac{L(s)}{1 + L(s)}. \]

The closed loop is stable if and only if every root of the characteristic equation

\[ 1 + L(s) = 0 \]

(i.e. every closed-loop pole) lies in the left half-plane (negative real part). The challenge is bridging the easily measured open-loop characteristic \(L(s)\) to the quantity we care about — closed-loop stability. Nyquist plots, root locus, and stability margins each build that bridge.

Stability Margins: Gain Margin and Phase Margin

Stability margins quantify how much room a stable system has before it becomes unstable, using the open-loop frequency response \(L(j\omega)\) .

Gain Margin (GM)

The frequency at which the phase reaches \(-180^\circ\) is the phase-crossover frequency \(\omega_{pc}\) . The gain margin measures how much further the open-loop gain can be raised there before \(|L(j\omega_{pc})| = 1\) (instability):

\[ \mathrm{GM} = \frac{1}{|L(j\omega_{pc})|}, \quad \mathrm{GM}[\mathrm{dB}] = -20\log_{10}|L(j\omega_{pc})|. \]

Phase Margin (PM)

The frequency at which the gain equals \(1\) (\(0\,\mathrm{dB}\) ) is the gain-crossover frequency \(\omega_{gc}\) . The phase margin measures how many more degrees the phase must rotate there to reach \(-180^\circ\) :

\[ \mathrm{PM} = 180^\circ + \angle L(j\omega_{gc}). \]

In practice, a phase margin of \(45^\circ\) –\(60^\circ\) is a good balance between stability and response speed. The smaller the phase margin, the more oscillatory (larger overshoot) the closed-loop response.

Nyquist Plot

Definition

The Nyquist plot traces \(L(j\omega)\) on the complex plane as \(\omega\) runs from \(-\infty\) to \(+\infty\) . Whereas the Bode plot splits magnitude and phase into two separate panels, the Nyquist plot draws a single locus on the complex plane.

The Nyquist Stability Criterion

Closed-loop stability is determined by the number of encirclements of the point \(-1 + 0j\) (the critical point):

\[ Z = N + P \]
  • \(Z\) : number of closed-loop poles in the right half-plane (\(Z = 0\) means stable),
  • \(P\) : number of open-loop poles in the right half-plane,
  • \(N\) : number of clockwise encirclements of \(-1\) by the Nyquist locus.

If \(L(s)\) is open-loop stable (\(P = 0\) ), the closed loop is stable when the locus does not encircle \(-1\) (\(N = 0\) ). Stability margins can be seen as another way of measuring how close the locus comes to this critical point.

Root Locus

Definition

The root locus traces how the closed-loop poles (roots of \(1 + K L_0(s) = 0\) ) move on the complex plane as the gain \(K\) varies from \(0\) to \(\infty\) . Writing \(L(s) = K L_0(s)\) , the characteristic equation becomes

\[ \underbrace{\mathrm{den}(s)}_{\text{denominator of } L_0} + K \cdot \underbrace{\mathrm{num}(s)}_{\text{numerator of } L_0} = 0, \]

so evaluating the roots of this polynomial for each \(K\) yields the locus. At \(K = 0\) the roots coincide with the open-loop poles; increasing \(K\) moves them. The gain at which a pole crosses the imaginary axis is the stability limit, where the system exhibits sustained oscillation (marginal stability).

Python Implementation

Computing Stability Margins from the Frequency Response

Use scipy.signal.freqresp for the open-loop frequency response, then locate the gain and phase crossovers numerically.

import numpy as np
from scipy import signal

# Open-loop transfer function L(s) = 1 / (s(s+1)(s+2))   (K=1)
num = [1.0]
den = np.polymul(np.polymul([1, 0], [1, 1]), [1, 2])  # s(s+1)(s+2) = s^3+3s^2+2s
sys = signal.TransferFunction(num, den)

# Frequency response
w = np.logspace(-2, 2, 2000)
w, H = signal.freqresp(sys, w)
mag = np.abs(H)
phase = np.unwrap(np.angle(H))

# Gain-crossover frequency (|L|=1) -> phase margin
idx_gc = np.argmin(np.abs(mag - 1.0))
wgc = w[idx_gc]
pm = 180 + np.degrees(phase[idx_gc])
print(f"gain crossover wgc = {wgc:.4f} rad/s,  phase margin PM = {pm:.2f} deg")

# Phase-crossover frequency (angle L = -180 deg) -> gain margin
idx_pc = np.argmin(np.abs(phase - (-np.pi)))
wpc = w[idx_pc]
gm_db = -20 * np.log10(mag[idx_pc])
print(f"phase crossover wpc = {wpc:.4f} rad/s,  gain margin GM = {gm_db:.2f} dB")

The output is:

gain crossover wgc = 0.4455 rad/s,  phase margin PM = 53.43 deg
phase crossover wpc = 1.4160 rad/s,  gain margin GM = 15.59 dB

Let us check against the theory. For \(L(j\omega) = \dfrac{1}{j\omega(j\omega+1)(j\omega+2)}\) , the phase reaches \(-180^\circ\) where the imaginary part of the denominator vanishes, at \(\omega_{pc} = \sqrt{2} \approx 1.414\) . There \(|L(j\omega_{pc})| = \dfrac{1}{\sqrt{2}\cdot\sqrt{3}\cdot\sqrt{6}} = \dfrac{1}{6}\) , so the gain margin is \(20\log_{10} 6 \approx 15.56\,\mathrm{dB}\) , matching the numerical \(15.59\,\mathrm{dB}\) . A gain margin of \(6\times\) means the loop reaches the stability limit when \(K\) is raised sixfold.

Drawing the Nyquist Plot

import matplotlib.pyplot as plt

w_pos = np.logspace(-2, 2, 2000)
_, H_pos = signal.freqresp(sys, w_pos)

fig, ax = plt.subplots(figsize=(6, 6))
ax.plot(H_pos.real, H_pos.imag, "b", label="ω > 0")
ax.plot(H_pos.real, -H_pos.imag, "b--", label="ω < 0")  # symmetric about real axis
ax.plot(-1, 0, "rx", markersize=12, label="critical point -1")
ax.set_xlabel("Real")
ax.set_ylabel("Imaginary")
ax.set_title("Nyquist plot: L(s)=1/(s(s+1)(s+2))")
ax.grid(True)
ax.axhline(0, color="k", lw=0.5)
ax.axvline(0, color="k", lw=0.5)
ax.legend()
ax.set_xlim(-1.5, 0.5)
ax.set_ylim(-2, 2)
plt.tight_layout()
plt.show()

At \(K = 1\) the locus does not encircle \(-1\) (\(N = 0\) , and with \(P = 0\) gives \(Z = 0\) ), so the closed loop is stable. The locus crosses the real axis at \(-1/6 \approx -0.167\) ; raising the gain sixfold moves that crossing exactly onto \(-1\) — the Nyquist plot and the gain margin express the same fact.

Drawing the Root Locus

Sweep \(K\) and compute the roots of \(\mathrm{den}(s) + K \cdot \mathrm{num}(s) = 0\) with numpy.roots.

Ks = np.linspace(0, 12, 400)
roots_all = []
for K in Ks:
    charpoly = den.astype(float).copy()
    charpoly[-len(num):] += K * np.array(num, dtype=float)
    roots_all.append(np.sort_complex(np.roots(charpoly)))
roots_all = np.array(roots_all)

fig, ax = plt.subplots(figsize=(6, 6))
for i in range(roots_all.shape[1]):
    ax.plot(roots_all[:, i].real, roots_all[:, i].imag, ".", ms=2)
ax.axvline(0, color="r", lw=1, ls="--", label="imaginary axis (stability limit)")
ax.set_xlabel("Real")
ax.set_ylabel("Imaginary")
ax.set_title("Root locus")
ax.grid(True)
ax.legend()
plt.tight_layout()
plt.show()

# Confirm the stability-limit gain
for K in [0, 2, 4, 6, 8]:
    charpoly = den.astype(float).copy()
    charpoly[-len(num):] += K * np.array(num, dtype=float)
    r = np.roots(charpoly)
    print(f"K={K:2d}: max Re = {r.real.max():+.3f}  poles={np.round(r,3)}")

The output is:

K= 0: max Re = +0.000  poles=[-2. -1.  0.]
K= 2: max Re = -0.239  poles=[-2.521+0.j    -0.239+0.858j -0.239-0.858j]
K= 4: max Re = -0.102  poles=[-2.796+0.j    -0.102+1.192j -0.102-1.192j]
K= 6: max Re = +0.000  poles=[-3.+0.j     0.+1.414j  0.-1.414j]
K= 8: max Re = +0.083  poles=[-3.166+0.j     0.083+1.587j  0.083-1.587j]

At \(K = 6\) a pair of poles lands exactly on the imaginary axis at \(\pm j\sqrt{2}\) (marginal stability), and for \(K > 6\) they enter the right half-plane and destabilize the loop. This matches the gain margin of \(6\times\) and phase-crossover frequency \(\sqrt{2}\) obtained from the frequency response. The numerical experiment confirms that all three methods (stability margins, Nyquist, root locus) point to the same stability limit.

A Reusable Margin Function with scipy.signal Only

def stability_margins(num, den, w=None):
    """Return GM[dB], PM[deg], wpc, wgc of the open loop (num/den)."""
    if w is None:
        w = np.logspace(-3, 3, 5000)
    sys = signal.TransferFunction(num, den)
    w, H = signal.freqresp(sys, w)
    mag, phase = np.abs(H), np.unwrap(np.angle(H))
    i_gc = np.argmin(np.abs(mag - 1.0))
    i_pc = np.argmin(np.abs(phase - (-np.pi)))
    gm_db = -20 * np.log10(mag[i_pc])
    pm = 180 + np.degrees(phase[i_gc])
    return dict(GM_dB=gm_db, PM_deg=pm, wpc=w[i_pc], wgc=w[i_gc])

print(stability_margins([1.0], [1, 3, 2, 0]))

Design Guidelines

Estimating Damping from the Phase Margin

For a second-order approximation, the phase margin \(\mathrm{PM}\) and damping ratio \(\zeta\) satisfy the rule of thumb \(\zeta \approx \mathrm{PM}[\deg] / 100\) . Here \(\mathrm{PM} = 53.4^\circ\) corresponds to \(\zeta \approx 0.53\) , promising a well-behaved response with moderate overshoot (about \(14\%\) ). Too large a phase margin makes the response sluggish; too small makes it oscillatory — hence the \(45^\circ\) –\(60^\circ\) practical target.

Correspondence with the Bode Plot

Stability margins are also read directly off the Bode plot: the phase margin is the gap between the phase at the gain crossover and \(-180^\circ\) ; the gain margin is the gap between the gain at the phase crossover and \(0\,\mathrm{dB}\) . The Nyquist plot unifies both into a single picture (“closeness to the critical point”), while the root locus shows “the motion of the poles as the gain changes” — three complementary views of the same stability.

A Note on Discrete-Time Systems

In digital control, the left half of the \(s\) -plane maps to the interior of the unit circle in the \(z\) -plane, so the critical point and the imaginary axis are replaced by the unit circle. When implementing a continuous-time design by sampling, choose the sampling frequency per the sampling theorem and account for the extra phase lag from discretization (which erodes the phase margin).

Summary

  • Stability margins (gain and phase margin) quantify the safety margin of closed-loop stability from the open-loop response \(L(j\omega)\) ; a phase margin of \(45^\circ\) –\(60^\circ\) is the practical target.
  • The Nyquist plot draws \(L(j\omega)\) on the complex plane and determines stability from encirclements of the critical point \(-1\) (\(Z = N + P\) ).
  • The root locus is the trajectory of closed-loop poles as the gain \(K\) sweeps; the gain at which a pole crosses the imaginary axis is the stability limit.
  • For \(L(s) = K/(s(s+1)(s+2))\) , the phase margin \(53.4^\circ\) , gain margin \(15.6\,\mathrm{dB}\) , and stability limit \(K = 6\) agree perfectly, confirmed with scipy.signal and numpy.roots.
  • Even without the control package, stability margins, the Nyquist plot, and the root locus can all be implemented with just the frequency response (freqresp) and polynomial roots (roots).

References