Introduction

Every thermostat, cruise control and drone autopilot is a feedback loop: measure the error, push back against it, measure again. Push back too gently and the system drifts. Push back too hard, or too late, and it starts to oscillate — sometimes settling into a wobble, sometimes shaking itself apart.

The unsettling part is that a loop can look perfectly reasonable at a glance and still be one small change away from disaster: a slightly heavier load, a longer cable, a firmware update that adds a few milliseconds of delay. How do engineers know, before switching it on, whether a design has room to spare or is teetering on the edge?

The answer is a pair of numbers you can read straight off a frequency response plot: the gain margin and the phase margin. They turn "will this system blow up?" into a question you can answer by inspecting a graph.

Slide the Gain, Watch the Margins

Below is a simple third-order feedback loop. Slide the loop gain KK and watch two things at once: the Bode magnitude/phase plot, and the step response on the right.

<p class="hint">The loop is L(s) = K / (s+1)^3 with unity feedback — a simple textbook model that goes unstable once the gain K passes 8. Slide K and watch the gain margin (GM) and phase margin (PM) shrink.</p>
<div class="controls">
  <label for="gain">Loop gain K = <span id="gainVal">3.0</span></label>
  <input type="range" id="gain" min="1" max="12" step="0.1" value="3">
</div>
<div class="readouts">
  <div class="ro"><span class="ro-label">Gain margin</span><span id="gmVal" class="ro-val">--</span></div>
  <div class="ro"><span class="ro-label">Phase margin</span><span id="pmVal" class="ro-val">--</span></div>
  <div class="ro" id="verdictRow"><span class="ro-label">Verdict</span><span id="verdictVal" class="ro-val">--</span></div>
</div>
<div class="plots">
  <div class="plot-col">
    <div class="plot-title">Bode plot (magnitude, top — phase, bottom)</div>
    <svg id="bodeSvg" viewBox="0 0 320 220" class="plot"></svg>
  </div>
  <div class="plot-col">
    <div class="plot-title">Closed-loop step response</div>
    <svg id="stepSvg" viewBox="0 0 320 220" class="plot"></svg>
  </div>
</div>
<div class="status" id="status">Move the slider to see the margins change.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; flex-wrap: wrap; }
.controls label { font-size: .9rem; font-weight: 600; color: #1d3557; display: flex; align-items: center; gap: .4rem; }
.controls input[type="range"] { width: 220px; }
.readouts { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: .6rem; }
.ro { background: #eef2f6; border: 1px solid #cdd9e3; border-radius: 8px; padding: .35rem .7rem; font-size: .88rem; display: flex; gap: .4rem; }
.ro-label { color: #444; font-weight: 600; }
.ro-val { color: #1d3557; font-weight: 700; font-variant-numeric: tabular-nums; }
.ro.warn .ro-val { color: #c92f3c; }
.plots { display: flex; gap: .8rem; flex-wrap: wrap; }
.plot-col { flex: 1 1 260px; min-width: 240px; }
.plot-title { font-size: .82rem; font-weight: 600; color: #444; margin-bottom: .2rem; }
.plot { width: 100%; height: auto; background: #fbfcfd; border: 1px solid #dbe2e8; border-radius: 8px; }
.axis { stroke: #b6c0c9; stroke-width: 1; }
.gridline { stroke: #e5eaef; stroke-width: 1; }
.curve-mag { stroke: #1d3557; stroke-width: 2; fill: none; }
.curve-phase { stroke: #7a5cc9; stroke-width: 2; fill: none; }
.curve-step { stroke: #0a7d33; stroke-width: 2; fill: none; }
.marker { fill: #c92f3c; }
.refline { stroke: #c92f3c; stroke-width: 1; stroke-dasharray: 3 2; }
.plot-label { font-size: 7px; fill: #667; font-family: system-ui, sans-serif; }
.status { font-size: .95rem; font-weight: 600; margin: .6rem 0 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #b8860b; }
.status.bad { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Open-loop model: L(s) = K / (s+1)^3, a classic third-order textbook loop.
// Its magnitude and phase at frequency omega follow directly from the pole at s = -1 (multiplicity 3).
function loopMagPhase(omega, K) {
  const magPole = Math.pow(Math.sqrt(1 + omega * omega), 3);
  const phasePole = 3 * Math.atan2(omega, 1);
  return { mag: K / magPole, phaseDeg: -phasePole * 180 / Math.PI };
}

// Phase crossover frequency where phase(L) = -180 deg is fixed at sqrt(3) rad/s for this L(s), independent of K.
const W_PC = Math.sqrt(3);

// Bisection search for the frequency where |L(jw)| = 1 (0 dB), i.e. the gain crossover frequency.
function findGainCrossoverOmega(K) {
  let lo = 0.001, hi = 1000;
  if (loopMagPhase(lo, K).mag < 1) return null;
  for (let i = 0; i < 200; i++) {
    const mid = (lo + hi) / 2;
    if (loopMagPhase(mid, K).mag > 1) lo = mid; else hi = mid;
  }
  return (lo + hi) / 2;
}

function margins(K) {
  const atPc = loopMagPhase(W_PC, K);
  const gmDb = -20 * Math.log10(atPc.mag);
  const wgc = findGainCrossoverOmega(K);
  let pmDeg = null;
  if (wgc !== null) {
    const atGc = loopMagPhase(wgc, K);
    pmDeg = 180 + atGc.phaseDeg;
  }
  return { gmDb, pmDeg, wgc };
}

// Simulate the closed-loop step response by integrating the state-space form of K / (s^3+3s^2+3s+1+K) with RK4.
function simulateStep(K, T, dt) {
  let x = [0, 0, 0];
  const a0 = 1 + K, a1 = 3, a2 = 3;
  function deriv(s) { return [s[1], s[2], -(a0 * s[0] + a1 * s[1] + a2 * s[2]) + K]; }
  const ys = [];
  let t = 0;
  while (t <= T) {
    ys.push(x[0]);
    const k1 = deriv(x);
    const s2 = x.map((v, i) => v + dt / 2 * k1[i]);
    const k2 = deriv(s2);
    const s3 = x.map((v, i) => v + dt / 2 * k2[i]);
    const k3 = deriv(s3);
    const s4 = x.map((v, i) => v + dt * k3[i]);
    const k4 = deriv(s4);
    x = x.map((v, i) => v + dt / 6 * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]));
    t += dt;
  }
  return ys;
}

const SVG_NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs) {
  const e = document.createElementNS(SVG_NS, tag);
  for (const k in attrs) e.setAttribute(k, attrs[k]);
  return e;
}

const W = 320, H = 220, PAD_L = 34, PAD_R = 10, PAD_T = 10, PAD_B = 22;
const plotW = W - PAD_L - PAD_R, plotH = H - PAD_T - PAD_B;

// Fixed axis ranges chosen so all sliders (K from 1 to 12) stay readable without rescaling.
const LOG_W_MIN = -1.3, LOG_W_MAX = 1.3;
const MAG_MIN = -60, MAG_MAX = 30;
const PHASE_MIN = -270, PHASE_MAX = 0;

function xForLogW(logw) { return PAD_L + (logw - LOG_W_MIN) / (LOG_W_MAX - LOG_W_MIN) * plotW; }
function yForMag(db) { return PAD_T + (MAG_MAX - db) / (MAG_MAX - MAG_MIN) * (plotH / 2 - 4); }
function yForPhase(deg) { return PAD_T + plotH / 2 + 8 + (PHASE_MAX - deg) / (PHASE_MAX - PHASE_MIN) * (plotH / 2 - 8); }

function drawBode(K) {
  const svg = document.getElementById('bodeSvg');
  svg.innerHTML = '';
  svg.appendChild(el('line', { class: 'axis', x1: PAD_L, y1: PAD_T, x2: PAD_L, y2: H - PAD_B }));
  svg.appendChild(el('line', { class: 'axis', x1: PAD_L, y1: H - PAD_B, x2: W - PAD_R, y2: H - PAD_B }));
  svg.appendChild(el('line', { class: 'gridline', x1: PAD_L, y1: yForMag(0), x2: W - PAD_R, y2: yForMag(0) }));
  svg.appendChild(el('line', { class: 'gridline', x1: PAD_L, y1: yForPhase(-180), x2: W - PAD_R, y2: yForPhase(-180) }));

  const N = 120;
  let magPts = '', phasePts = '';
  for (let i = 0; i <= N; i++) {
    const logw = LOG_W_MIN + (LOG_W_MAX - LOG_W_MIN) * i / N;
    const w = Math.pow(10, logw);
    const { mag, phaseDeg } = loopMagPhase(w, K);
    const db = 20 * Math.log10(mag);
    const x = xForLogW(logw);
    magPts += x + ',' + Math.max(PAD_T, Math.min(H - PAD_B, yForMag(db))) + ' ';
    phasePts += x + ',' + Math.max(PAD_T, Math.min(H - PAD_B, yForPhase(phaseDeg))) + ' ';
  }
  svg.appendChild(el('polyline', { class: 'curve-mag', points: magPts.trim() }));
  svg.appendChild(el('polyline', { class: 'curve-phase', points: phasePts.trim() }));

  const { gmDb, pmDeg, wgc } = margins(K);
  const xPc = xForLogW(Math.log10(W_PC));
  svg.appendChild(el('line', { class: 'refline', x1: xPc, y1: yForMag(0), x2: xPc, y2: yForMag(-gmDb) }));
  svg.appendChild(el('circle', { class: 'marker', cx: xPc, cy: yForMag(0), r: 2.6 }));
  if (wgc !== null) {
    const xGc = xForLogW(Math.log10(wgc));
    const phAtGc = loopMagPhase(wgc, K).phaseDeg;
    svg.appendChild(el('line', { class: 'refline', x1: xGc, y1: yForPhase(-180), x2: xGc, y2: yForPhase(phAtGc) }));
    svg.appendChild(el('circle', { class: 'marker', cx: xGc, cy: yForPhase(phAtGc), r: 2.6 }));
  }
  const magLabel = el('text', { class: 'plot-label', x: PAD_L + 2, y: PAD_T + 8 });
  magLabel.textContent = '|L(jw)| in dB';
  svg.appendChild(magLabel);
  const phLabel = el('text', { class: 'plot-label', x: PAD_L + 2, y: PAD_T + plotH / 2 + 18 });
  phLabel.textContent = 'phase of L(jw) in degrees';
  svg.appendChild(phLabel);
}

function drawStep(K) {
  const svg = document.getElementById('stepSvg');
  svg.innerHTML = '';
  const T = 30, dt = 0.05;
  const ys = simulateStep(K, T, dt);
  const yMaxAbs = Math.max(2.2, ...ys.map((v) => Math.abs(v)));
  const yTop = yMaxAbs, yBot = -yMaxAbs * 0.3;
  function xForT(t) { return PAD_L + (t / T) * plotW; }
  function yForY(y) { return PAD_T + (yTop - y) / (yTop - yBot) * plotH; }

  svg.appendChild(el('line', { class: 'axis', x1: PAD_L, y1: PAD_T, x2: PAD_L, y2: H - PAD_B }));
  svg.appendChild(el('line', { class: 'axis', x1: PAD_L, y1: H - PAD_B, x2: W - PAD_R, y2: H - PAD_B }));
  svg.appendChild(el('line', { class: 'gridline', x1: PAD_L, y1: yForY(1), x2: W - PAD_R, y2: yForY(1) }));

  let pts = '';
  for (let i = 0; i < ys.length; i++) {
    const t = i * dt;
    const y = Math.max(yBot, Math.min(yTop, ys[i]));
    pts += xForT(t) + ',' + yForY(y) + ' ';
  }
  svg.appendChild(el('polyline', { class: 'curve-step', points: pts.trim() }));
  const refLabel = el('text', { class: 'plot-label', x: W - PAD_R - 60, y: yForY(1) - 3 });
  refLabel.textContent = 'target';
  svg.appendChild(refLabel);
}

function update() {
  const K = parseFloat(document.getElementById('gain').value);
  document.getElementById('gainVal').textContent = K.toFixed(1);
  const { gmDb, pmDeg } = margins(K);
  const gmText = isFinite(gmDb) ? gmDb.toFixed(1) + ' dB' : '--';
  const pmText = (pmDeg === null) ? 'n/a (never crosses 0 dB)' : pmDeg.toFixed(1) + '°';
  document.getElementById('gmVal').textContent = gmText;
  document.getElementById('pmVal').textContent = pmText;

  const gmRow = document.getElementById('gmVal').closest ? document.getElementById('gmVal').parentElement : null;
  const pmRow = document.getElementById('pmVal').parentElement;
  gmRow.classList.toggle('warn', gmDb < 6);
  pmRow.classList.toggle('warn', pmDeg !== null && pmDeg < 30);

  const verdictEl = document.getElementById('verdictVal');
  const verdictRow = document.getElementById('verdictRow');
  let statusText, statusClass;
  if (gmDb <= 0) {
    verdictEl.textContent = 'Unstable';
    verdictRow.classList.add('warn');
    statusText = 'The Nyquist curve now encircles -1: the closed loop is unstable and the response grows without bound.';
    statusClass = 'bad';
  } else if (gmDb < 6 || (pmDeg !== null && pmDeg < 30)) {
    verdictEl.textContent = 'Fragile';
    verdictRow.classList.add('warn');
    statusText = i18nFmt('GM = %gm% dB and PM = %pm% deg — stable, but a small extra delay or gain could tip this loop into oscillation.', { gm: gmDb.toFixed(1), pm: pmDeg !== null ? pmDeg.toFixed(0) : '--' });
    statusClass = 'warn';
  } else {
    verdictEl.textContent = 'Healthy';
    verdictRow.classList.remove('warn');
    statusText = i18nFmt('GM = %gm% dB and PM = %pm% deg — comfortable margins; the step response settles cleanly.', { gm: gmDb.toFixed(1), pm: pmDeg !== null ? pmDeg.toFixed(0) : '--' });
    statusClass = 'ok';
  }
  const statusEl = document.getElementById('status');
  statusEl.textContent = statusText;
  statusEl.className = 'status ' + statusClass;

  drawBode(K);
  drawStep(K);
  console.log('Bode/Nyquist demo update:', 'K=', K, 'GM(dB)=', gmDb, 'PM(deg)=', pmDeg);
}

document.getElementById('gain').addEventListener('input', update);
update();
Result
Console

Push KK up and the gain margin (how much extra gain would push the loop to instability) and the phase margin (how much extra phase lag it can tolerate at the frequency where gain crosses 0 dB) both shrink. Watch the step response grow ringier as the margins approach zero — and start to grow without bound once they cross it.

The Real Complexity

Why should a plot of gain and phase against frequency say anything about stability at all?

  • The hard way is to find the roots of the closed-loop characteristic equation and check that every one has negative real part. For high-order systems, or systems with time delay, that polynomial can be nasty or not even finite-dimensional.
  • Nyquist's insight (1932): plot the open-loop response L(jω)L(j\omega) as ω\omega sweeps from −∞-\infty to ∞\infty in the complex plane. Count how many times that curve encircles the point −1-1. The Nyquist stability criterion says the number of unstable closed-loop poles equals the number of unstable open-loop poles plus the number of clockwise encirclements of −1-1 — no root-finding required.
  • Gain and phase margins are a shortcut. At the frequency ωpc\omega_{pc} where the phase of L(jω)L(j\omega) crosses −180°-180°, the gain margin is how far ∣L(jωpc)∣|L(j\omega_{pc})| sits below 11 (in dB, GM=−20log⁡10∣L(jωpc)∣\text{GM} = -20\log_{10}|L(j\omega_{pc})|). At the frequency ωgc\omega_{gc} where ∣L(jωgc)∣=1|L(j\omega_{gc})| = 1 (0 dB), the phase margin is PM=180°+∠L(jωgc)\text{PM} = 180° + \angle L(j\omega_{gc}). Both measure the same thing as Nyquist's encirclement count: how far the curve sits from wrapping around −1-1.
  • Small margins mean fragility. A phase margin near 0°0° means a tiny extra delay — a sensor lag, a slower processor — flips the sign of every encirclement and the system rings or diverges. That is why control engineers routinely demand PM≳45°\text{PM} \gtrsim 45° and GM≳6-10 dB\text{GM} \gtrsim 6\text{-}10\ \text{dB} as a safety buffer, not because the nominal design is unstable, but because real systems are never exactly the model.

The elegance is topological: instead of computing where the poles are, Nyquist counts how many times a curve loops around a single point — turning an algebra problem into a geometry problem you can read off a plot.

Where It Matters

Any system built out of "measure, compare, correct" lives or dies by its margins:

  • Aerospace and drones: flight-control loops are certified partly by showing adequate gain and phase margin across the flight envelope, since airframe dynamics change with speed and altitude.
  • Process control: chemical plants and power grids rely on margin checks to stay stable even as loads, temperatures and delays drift over months of operation.
  • Controller tuning: methods that tune a PID controller explicitly target a phase margin (often 45-60°) as the design goal, trading responsiveness for safety.
  • Robust control: modern designs generalize margins into structured-uncertainty bounds, but the underlying question is identical — how much can the model be wrong before the real loop goes unstable?

Whenever a system pushes back against its own error, someone had to check its margins first — the same question sits underneath controllability and observability: can we even steer and see the system well enough to keep it safe?

Conclusion

Gain and phase margins compress a hard algebraic question — where exactly do the closed-loop poles sit? — into two numbers you can read off a frequency-response plot. Nyquist's encirclement count is the rigorous version; the margins are its everyday, practical shadow.

So the next time a control system behaves a little too perfectly, remember: somewhere behind it is a Bode plot showing just how much gain or phase it could still absorb before the feedback that keeps it steady turns into the feedback that shakes it apart.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/bode-nyquist-stability/Content licensed under CC BY-NC 4.0.