<p class="hint">This simulated loop has only a proportional gain active. Raise <b>K<sub>p</sub></b> and press Run — below a critical gain the response settles, above it the wobble grows without bound. Right at the edge it oscillates forever at constant amplitude: that is the ultimate gain and period.</p>
<div class="row">
<label for="kp">K_p</label>
<input type="range" id="kp" min="1" max="9" step="0.05" value="4">
<span class="kpval" id="kpval">4.00</span>
</div>
<canvas id="plot" width="640" height="220"></canvas>
<div class="readout" id="readout">K_u = ? T_u = ?</div>
<div class="status" id="status">Pick a gain and press Run.</div>
<div class="btns">
<button id="run" type="button">Run</button>
<button id="apply" type="button" disabled>Apply Ziegler-Nichols PID</button>
<button id="reset" type="button" class="ghost">Reset</button>
</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; }
.row { display: flex; align-items: center; gap: .6rem; margin: 0 0 .5rem; flex-wrap: wrap; }
.row label { font-size: .85rem; font-weight: 600; color: #1d3557; }
.row input[type="range"] { flex: 1; min-width: 140px; }
.kpval { font: 700 14px ui-monospace, monospace; color: #1d3557; min-width: 3.2em; }
canvas { width: 100%; max-width: 640px; height: 220px; background: #f4f6f8; border: 1px solid #cdd9e3;
border-radius: 8px; display: block; }
.readout { font-size: .85rem; color: #333; margin: .5rem 0 0; min-height: 1.3em; font-family: ui-monospace, monospace; }
.status { font-size: 1rem; font-weight: 600; margin: .35rem 0 .6rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c98a00; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Plant: three cascaded first-order lags (a classic textbook plant) driven by a
// proportional-only controller, unity feedback, setpoint = 1.
const DT = 0.05;
const TAU = 1.0;
const SETPOINT = 1;
const MAX_STEPS = 3000; // cap so the animation and the loop both terminate
const kpSlider = document.getElementById('kp');
const kpVal = document.getElementById('kpval');
const canvas = document.getElementById('plot');
const ctx = canvas.getContext('2d');
const readout = document.getElementById('readout');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
const applyBtn = document.getElementById('apply');
const resetBtn = document.getElementById('reset');
let mode = 'p'; // 'p' while hunting for Ku/Tu, 'pid' once tuned gains are applied
let history = [];
let running = false;
let timer = null;
let Ku = null, Tu = null;
let pidGains = null;
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = 'status ' + (cls || ''); }
// For a pure-P controller this plant settles at Kp/(Kp+1) of the setpoint if it does not oscillate.
function steadyOffset(Kp) { return (Kp / (Kp + 1)) * SETPOINT; }
// All turning points (peaks and troughs) of the response, used to detect real oscillation.
function findExtrema(h, center) {
const ext = [];
const eps = 1e-6;
for (let i = 1; i < h.length - 1; i++) {
const d0 = h[i] - h[i - 1];
const d1 = h[i + 1] - h[i];
if ((d0 > eps && d1 < -eps) || (d0 < -eps && d1 > eps)) ext.push(i);
}
return ext;
}
// Growing, decaying, or roughly steady amplitude around the offset marks the ultimate-gain boundary.
function classifyOscillation(h, Kp) {
const center = steadyOffset(Kp);
const ext = findExtrema(h, center);
if (ext.length < 6) return { kind: 'settled' };
// Real oscillation must swing above and below the offset in alternation, not drift on one side.
const signs = ext.map((i) => Math.sign(h[i] - center));
for (let i = 1; i < signs.length; i++) {
if (signs[i] === signs[i - 1]) return { kind: 'settled' };
}
const amp = (i) => Math.abs(h[ext[i]] - center);
const a0 = amp(2);
const a1 = amp(ext.length - 1);
if (a0 < 1e-3) return { kind: 'settled' };
const fullPeriods = [];
for (let i = 2; i + 2 < ext.length; i += 2) fullPeriods.push((ext[i + 2] - ext[i]) * DT);
const avgPeriod = fullPeriods.length ? fullPeriods.reduce((a, b) => a + b, 0) / fullPeriods.length : null;
const ratio = a1 / a0;
if (ratio > 1.35) return { kind: 'growing', period: avgPeriod };
if (ratio < 0.55) return { kind: 'decaying', period: avgPeriod };
return { kind: 'sustained', period: avgPeriod };
}
function stepP(state, Kp) {
const err = SETPOINT - state.y;
const u = Kp * err;
return advance(state, u);
}
function stepPID(state, gains) {
const err = SETPOINT - state.y;
state.integral += err * DT;
const deriv = (err - state.prevErr) / DT;
state.prevErr = err;
const u = gains.kp * err + gains.ki * state.integral + gains.kd * deriv;
return advance(state, u);
}
// One Euler integration step of the three-lag plant driven by control signal u.
function advance(state, u) {
const x = state.x;
const dx0 = (u - x[0]) / TAU;
const dx1 = (x[0] - x[1]) / TAU;
const dx2 = (x[1] - x[2]) / TAU;
x[0] += dx0 * DT;
x[1] += dx1 * DT;
x[2] += dx2 * DT;
state.y = x[2];
return state.y;
}
function drawPlot() {
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath();
const midY = h * 0.55;
ctx.moveTo(0, midY);
ctx.lineTo(w, midY);
ctx.stroke();
if (history.length < 2) return;
const n = history.length;
const maxAbs = Math.max(1.5, ...history.map((v) => Math.abs(v - SETPOINT))) * 1.15;
const scaleY = (h * 0.42) / maxAbs;
const stepX = w / Math.max(1, Math.min(n, MAX_STEPS) - 1);
const start = Math.max(0, n - MAX_STEPS);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = start; i < n; i++) {
const px = (i - start) * stepX;
const py = midY - (history[i] - SETPOINT) * scaleY;
if (i === start) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}
function resetSim() {
running = false;
if (timer) clearInterval(timer);
history = [];
drawPlot();
setStatus('Pick a gain and press Run.', '');
readout.textContent = 'K_u = ? T_u = ?';
}
function runP() {
mode = 'p';
applyBtn.disabled = true;
Ku = null;
Tu = null;
const Kp = parseFloat(kpSlider.value);
const state = { x: [0, 0, 0], y: 0 };
history = [0];
running = true;
setStatus('Running...', '');
let steps = 0;
timer = setInterval(() => {
for (let i = 0; i < 4 && steps < MAX_STEPS; i++, steps++) {
stepP(state, Kp);
history.push(state.y);
}
drawPlot();
if (steps < 1200) return; // wait for a few oscillation cycles before judging growth or decay
const result = classifyOscillation(history, Kp);
if (result.kind === 'sustained') {
Ku = Kp;
Tu = result.period;
setStatus(i18nFmt('Sustained oscillation found: K_u = %ku%, T_u = %tu%.', { ku: Ku.toFixed(2), tu: Tu.toFixed(2) }), 'ok');
readout.textContent = i18nFmt('K_u = %ku% T_u = %tu% s', { ku: Ku.toFixed(2), tu: Tu.toFixed(2) });
applyBtn.disabled = false;
clearInterval(timer);
running = false;
} else if (result.kind === 'growing') {
setStatus('Growing — the gain is too high, lower it.', 'bad');
} else if (result.kind === 'decaying') {
setStatus('Settling — try a higher gain to find the edge.', 'warn');
}
if (steps >= MAX_STEPS) {
clearInterval(timer);
running = false;
if (!Ku) setStatus('Inconclusive after this run — nudge the gain and try again.', 'warn');
}
}, 16);
}
function applyPID() {
if (!Ku || !Tu) return;
mode = 'pid';
pidGains = { kp: 0.6 * Ku, ki: (1.2 * Ku) / Tu, kd: 0.075 * Ku * Tu };
readout.textContent = i18nFmt('PID gains -> K_p = %kp%, K_i = %ki%, K_d = %kd%', {
kp: pidGains.kp.toFixed(3),
ki: pidGains.ki.toFixed(3),
kd: pidGains.kd.toFixed(3),
});
const state = { x: [0, 0, 0], y: 0, integral: 0, prevErr: 0 };
history = [0];
running = true;
setStatus('Running the tuned PID controller...', '');
let steps = 0;
timer = setInterval(() => {
for (let i = 0; i < 4 && steps < MAX_STEPS; i++, steps++) {
stepPID(state, pidGains);
history.push(state.y);
}
drawPlot();
if (steps >= MAX_STEPS) {
clearInterval(timer);
running = false;
const settledNear = Math.abs(state.y - SETPOINT) < 0.05;
setStatus(settledNear ? 'PID controller settled near the setpoint — no oscillation needed.' : 'Still converging — real loops often need a longer run or hand-refinement.', settledNear ? 'ok' : 'warn');
}
}, 16);
}
kpSlider.addEventListener('input', () => {
kpVal.textContent = parseFloat(kpSlider.value).toFixed(2);
});
runBtn.addEventListener('click', () => {
if (running) return;
runP();
});
applyBtn.addEventListener('click', () => {
if (running) return;
applyPID();
});
resetBtn.addEventListener('click', resetSim);
drawPlot();
<p class="hint">This simulated loop has only a proportional gain active. Raise <b>K<sub>p</sub></b> and press Run — below a critical gain the response settles, above it the wobble grows without bound. Right at the edge it oscillates forever at constant amplitude: that is the ultimate gain and period.</p>
<div class="row">
<label for="kp">K_p</label>
<input type="range" id="kp" min="1" max="9" step="0.05" value="4">
<span class="kpval" id="kpval">4.00</span>
</div>
<canvas id="plot" width="640" height="220"></canvas>
<div class="readout" id="readout">K_u = ? T_u = ?</div>
<div class="status" id="status">Pick a gain and press Run.</div>
<div class="btns">
<button id="run" type="button">Run</button>
<button id="apply" type="button" disabled>Apply Ziegler-Nichols PID</button>
<button id="reset" type="button" class="ghost">Reset</button>
</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; }
.row { display: flex; align-items: center; gap: .6rem; margin: 0 0 .5rem; flex-wrap: wrap; }
.row label { font-size: .85rem; font-weight: 600; color: #1d3557; }
.row input[type="range"] { flex: 1; min-width: 140px; }
.kpval { font: 700 14px ui-monospace, monospace; color: #1d3557; min-width: 3.2em; }
canvas { width: 100%; max-width: 640px; height: 220px; background: #f4f6f8; border: 1px solid #cdd9e3;
border-radius: 8px; display: block; }
.readout { font-size: .85rem; color: #333; margin: .5rem 0 0; min-height: 1.3em; font-family: ui-monospace, monospace; }
.status { font-size: 1rem; font-weight: 600; margin: .35rem 0 .6rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c98a00; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Plant: three cascaded first-order lags (a classic textbook plant) driven by a
// proportional-only controller, unity feedback, setpoint = 1.
const DT = 0.05;
const TAU = 1.0;
const SETPOINT = 1;
const MAX_STEPS = 3000; // cap so the animation and the loop both terminate
const kpSlider = document.getElementById('kp');
const kpVal = document.getElementById('kpval');
const canvas = document.getElementById('plot');
const ctx = canvas.getContext('2d');
const readout = document.getElementById('readout');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
const applyBtn = document.getElementById('apply');
const resetBtn = document.getElementById('reset');
let mode = 'p'; // 'p' while hunting for Ku/Tu, 'pid' once tuned gains are applied
let history = [];
let running = false;
let timer = null;
let Ku = null, Tu = null;
let pidGains = null;
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = 'status ' + (cls || ''); }
// For a pure-P controller this plant settles at Kp/(Kp+1) of the setpoint if it does not oscillate.
function steadyOffset(Kp) { return (Kp / (Kp + 1)) * SETPOINT; }
// All turning points (peaks and troughs) of the response, used to detect real oscillation.
function findExtrema(h, center) {
const ext = [];
const eps = 1e-6;
for (let i = 1; i < h.length - 1; i++) {
const d0 = h[i] - h[i - 1];
const d1 = h[i + 1] - h[i];
if ((d0 > eps && d1 < -eps) || (d0 < -eps && d1 > eps)) ext.push(i);
}
return ext;
}
// Growing, decaying, or roughly steady amplitude around the offset marks the ultimate-gain boundary.
function classifyOscillation(h, Kp) {
const center = steadyOffset(Kp);
const ext = findExtrema(h, center);
if (ext.length < 6) return { kind: 'settled' };
// Real oscillation must swing above and below the offset in alternation, not drift on one side.
const signs = ext.map((i) => Math.sign(h[i] - center));
for (let i = 1; i < signs.length; i++) {
if (signs[i] === signs[i - 1]) return { kind: 'settled' };
}
const amp = (i) => Math.abs(h[ext[i]] - center);
const a0 = amp(2);
const a1 = amp(ext.length - 1);
if (a0 < 1e-3) return { kind: 'settled' };
const fullPeriods = [];
for (let i = 2; i + 2 < ext.length; i += 2) fullPeriods.push((ext[i + 2] - ext[i]) * DT);
const avgPeriod = fullPeriods.length ? fullPeriods.reduce((a, b) => a + b, 0) / fullPeriods.length : null;
const ratio = a1 / a0;
if (ratio > 1.35) return { kind: 'growing', period: avgPeriod };
if (ratio < 0.55) return { kind: 'decaying', period: avgPeriod };
return { kind: 'sustained', period: avgPeriod };
}
function stepP(state, Kp) {
const err = SETPOINT - state.y;
const u = Kp * err;
return advance(state, u);
}
function stepPID(state, gains) {
const err = SETPOINT - state.y;
state.integral += err * DT;
const deriv = (err - state.prevErr) / DT;
state.prevErr = err;
const u = gains.kp * err + gains.ki * state.integral + gains.kd * deriv;
return advance(state, u);
}
// One Euler integration step of the three-lag plant driven by control signal u.
function advance(state, u) {
const x = state.x;
const dx0 = (u - x[0]) / TAU;
const dx1 = (x[0] - x[1]) / TAU;
const dx2 = (x[1] - x[2]) / TAU;
x[0] += dx0 * DT;
x[1] += dx1 * DT;
x[2] += dx2 * DT;
state.y = x[2];
return state.y;
}
function drawPlot() {
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath();
const midY = h * 0.55;
ctx.moveTo(0, midY);
ctx.lineTo(w, midY);
ctx.stroke();
if (history.length < 2) return;
const n = history.length;
const maxAbs = Math.max(1.5, ...history.map((v) => Math.abs(v - SETPOINT))) * 1.15;
const scaleY = (h * 0.42) / maxAbs;
const stepX = w / Math.max(1, Math.min(n, MAX_STEPS) - 1);
const start = Math.max(0, n - MAX_STEPS);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = start; i < n; i++) {
const px = (i - start) * stepX;
const py = midY - (history[i] - SETPOINT) * scaleY;
if (i === start) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}
function resetSim() {
running = false;
if (timer) clearInterval(timer);
history = [];
drawPlot();
setStatus('Pick a gain and press Run.', '');
readout.textContent = 'K_u = ? T_u = ?';
}
function runP() {
mode = 'p';
applyBtn.disabled = true;
Ku = null;
Tu = null;
const Kp = parseFloat(kpSlider.value);
const state = { x: [0, 0, 0], y: 0 };
history = [0];
running = true;
setStatus('Running...', '');
let steps = 0;
timer = setInterval(() => {
for (let i = 0; i < 4 && steps < MAX_STEPS; i++, steps++) {
stepP(state, Kp);
history.push(state.y);
}
drawPlot();
if (steps < 1200) return; // wait for a few oscillation cycles before judging growth or decay
const result = classifyOscillation(history, Kp);
if (result.kind === 'sustained') {
Ku = Kp;
Tu = result.period;
setStatus(i18nFmt('Sustained oscillation found: K_u = %ku%, T_u = %tu%.', { ku: Ku.toFixed(2), tu: Tu.toFixed(2) }), 'ok');
readout.textContent = i18nFmt('K_u = %ku% T_u = %tu% s', { ku: Ku.toFixed(2), tu: Tu.toFixed(2) });
applyBtn.disabled = false;
clearInterval(timer);
running = false;
} else if (result.kind === 'growing') {
setStatus('Growing — the gain is too high, lower it.', 'bad');
} else if (result.kind === 'decaying') {
setStatus('Settling — try a higher gain to find the edge.', 'warn');
}
if (steps >= MAX_STEPS) {
clearInterval(timer);
running = false;
if (!Ku) setStatus('Inconclusive after this run — nudge the gain and try again.', 'warn');
}
}, 16);
}
function applyPID() {
if (!Ku || !Tu) return;
mode = 'pid';
pidGains = { kp: 0.6 * Ku, ki: (1.2 * Ku) / Tu, kd: 0.075 * Ku * Tu };
readout.textContent = i18nFmt('PID gains -> K_p = %kp%, K_i = %ki%, K_d = %kd%', {
kp: pidGains.kp.toFixed(3),
ki: pidGains.ki.toFixed(3),
kd: pidGains.kd.toFixed(3),
});
const state = { x: [0, 0, 0], y: 0, integral: 0, prevErr: 0 };
history = [0];
running = true;
setStatus('Running the tuned PID controller...', '');
let steps = 0;
timer = setInterval(() => {
for (let i = 0; i < 4 && steps < MAX_STEPS; i++, steps++) {
stepPID(state, pidGains);
history.push(state.y);
}
drawPlot();
if (steps >= MAX_STEPS) {
clearInterval(timer);
running = false;
const settledNear = Math.abs(state.y - SETPOINT) < 0.05;
setStatus(settledNear ? 'PID controller settled near the setpoint — no oscillation needed.' : 'Still converging — real loops often need a longer run or hand-refinement.', settledNear ? 'ok' : 'warn');
}
}, 16);
}
kpSlider.addEventListener('input', () => {
kpVal.textContent = parseFloat(kpSlider.value).toFixed(2);
});
runBtn.addEventListener('click', () => {
if (running) return;
runP();
});
applyBtn.addEventListener('click', () => {
if (running) return;
applyPID();
});
resetBtn.addEventListener('click', resetSim);
drawPlot();
Comments
Loading comments...