Below is a simple third-order feedback loop. Slide the loop gain K 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();
<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();
Comments
Loading comments...