Below is a simple plant (think: a heater or a motor) driven by a PID controller whose actuator is capped between 0% and 100%. Press Run step to command a big jump in setpoint and watch the response.
<p class="hint">A PID controller drives a lagging plant (like a heater) toward a setpoint, but the actuator can only output between 0% and 100%. Toggle anti-windup and press Run to compare the response.</p>
<div class="controls">
<label class="toggle">
<input type="checkbox" id="awToggle" checked>
<span>Anti-windup (integral clamping)</span>
</label>
<button id="run" type="button">Run step</button>
<button id="reset" type="button" class="ghost">Clear chart</button>
</div>
<canvas id="chart" width="640" height="300" aria-label="Chart of setpoint, plant output and actuator command over time"></canvas>
<div class="legend">
<span><i class="sw sp"></i>Setpoint</span>
<span><i class="sw pv"></i>Plant output</span>
<span><i class="sw ac"></i>Actuator command</span>
</div>
<div class="status" id="status">Press "Run step" to command a jump in setpoint.</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; gap: .9rem; align-items: center; flex-wrap: wrap; margin-bottom: .6rem; }
.toggle { display: flex; align-items: center; gap: .4rem; font: 600 14px system-ui, sans-serif; cursor: pointer; user-select: none; }
.toggle input { width: 16px; height: 16px; cursor: pointer; }
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; }
canvas { width: 100%; max-width: 640px; height: auto; background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; display: block; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .82rem; margin: .5rem 0; color: #444; }
.legend span { display: flex; align-items: center; gap: .35rem; }
.sw { width: 14px; height: 3px; display: inline-block; border-radius: 2px; }
.sw.sp { background: #94a3b8; }
.sw.pv { background: #1d3557; }
.sw.ac { background: #e63946; }
.status { font-size: .92rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PID + saturated actuator simulation. KP/KI/KD are fixed gains; U_MIN/U_MAX model a
// valve or motor that can only push between 0% and 100% output.
const DT = 0.1;
const STEPS = 300;
const SETPOINT = 80;
const KP = 4, KI = 2.5, KD = 0;
const U_MIN = 0, U_MAX = 100;
// First-order lagging plant: dy/dt = (-y + K_PLANT * u) / TAU (Euler-integrated).
const TAU = 3, K_PLANT = 1;
// Runs one full step response, returning the output trace, actuator trace and overshoot.
function simulate(antiwindup) {
let y = 0, integral = 0, prevError = 0;
let maxY = 0;
const ys = [], us = [];
for (let i = 0; i < STEPS; i++) {
const error = SETPOINT - y;
let newIntegral = integral + error * DT;
let uUnsat = KP * error + KI * newIntegral + KD * (error - prevError) / DT;
let uSat = Math.max(U_MIN, Math.min(U_MAX, uUnsat));
if (antiwindup) {
// Integral clamping: freeze the integral only when saturated AND the error would push it further into saturation.
const wouldSaturate = uSat !== uUnsat;
const pushingFurther = (uUnsat > U_MAX && error > 0) || (uUnsat < U_MIN && error < 0);
if (!(wouldSaturate && pushingFurther)) integral = newIntegral;
uUnsat = KP * error + KI * integral + KD * (error - prevError) / DT;
uSat = Math.max(U_MIN, Math.min(U_MAX, uUnsat));
} else {
integral = newIntegral; // No anti-windup — keep integrating no matter what the actuator can actually do.
}
const dydt = (-y + K_PLANT * uSat) / TAU;
y = y + dydt * DT;
prevError = error;
maxY = Math.max(maxY, y);
ys.push(y); us.push(uSat);
}
const overshoot = Math.max(0, ((maxY - SETPOINT) / SETPOINT) * 100);
return { ys, us, overshoot };
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function draw(ys, us) {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const padL = 34, padB = 22, padT = 10, padR = 8;
const plotW = W - padL - padR, plotH = H - padT - padB;
const yMax = 110;
const xOf = i => padL + (i / (STEPS - 1)) * plotW;
const yOf = v => padT + plotH - (v / yMax) * plotH;
// Draw plot axes and gridlines.
ctx.strokeStyle = '#d7dee5'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, padT); ctx.lineTo(padL, padT + plotH); ctx.lineTo(padL + plotW, padT + plotH);
ctx.stroke();
ctx.fillStyle = '#8892a0'; ctx.font = '11px system-ui, sans-serif';
ctx.fillText('100', 2, yOf(100) + 4);
ctx.fillText('0', 18, yOf(0) + 4);
// setpoint line
ctx.strokeStyle = '#94a3b8'; ctx.setLineDash([5, 4]); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(xOf(0), yOf(SETPOINT)); ctx.lineTo(xOf(STEPS - 1), yOf(SETPOINT)); ctx.stroke();
ctx.setLineDash([]);
// actuator command
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.5;
ctx.beginPath();
us.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
ctx.stroke();
// plant output
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2.2;
ctx.beginPath();
ys.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
ctx.stroke();
}
function run() {
const aw = document.getElementById('awToggle').checked;
const { ys, us, overshoot } = simulate(aw);
draw(ys, us);
const s = document.getElementById('status');
if (aw) {
s.textContent = i18nFmt('Anti-windup ON — overshoot: %pct%%. The integral stops accumulating the instant the actuator saturates.', { pct: overshoot.toFixed(1) });
} else {
s.textContent = i18nFmt('Anti-windup OFF — overshoot: %pct%%. The integral kept growing while the actuator was pinned at its limit.', { pct: overshoot.toFixed(1) });
}
console.log('Overshoot over setpoint:', overshoot.toFixed(2) + '%');
}
document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
const ctx2 = canvas.getContext('2d');
ctx2.clearRect(0, 0, canvas.width, canvas.height);
document.getElementById('status').textContent = 'Press "Run step" to command a jump in setpoint.';
});
run();
<p class="hint">A PID controller drives a lagging plant (like a heater) toward a setpoint, but the actuator can only output between 0% and 100%. Toggle anti-windup and press Run to compare the response.</p>
<div class="controls">
<label class="toggle">
<input type="checkbox" id="awToggle" checked>
<span>Anti-windup (integral clamping)</span>
</label>
<button id="run" type="button">Run step</button>
<button id="reset" type="button" class="ghost">Clear chart</button>
</div>
<canvas id="chart" width="640" height="300" aria-label="Chart of setpoint, plant output and actuator command over time"></canvas>
<div class="legend">
<span><i class="sw sp"></i>Setpoint</span>
<span><i class="sw pv"></i>Plant output</span>
<span><i class="sw ac"></i>Actuator command</span>
</div>
<div class="status" id="status">Press "Run step" to command a jump in setpoint.</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; gap: .9rem; align-items: center; flex-wrap: wrap; margin-bottom: .6rem; }
.toggle { display: flex; align-items: center; gap: .4rem; font: 600 14px system-ui, sans-serif; cursor: pointer; user-select: none; }
.toggle input { width: 16px; height: 16px; cursor: pointer; }
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; }
canvas { width: 100%; max-width: 640px; height: auto; background: #f7f9fb; border: 1px solid #d7dee5; border-radius: 8px; display: block; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .82rem; margin: .5rem 0; color: #444; }
.legend span { display: flex; align-items: center; gap: .35rem; }
.sw { width: 14px; height: 3px; display: inline-block; border-radius: 2px; }
.sw.sp { background: #94a3b8; }
.sw.pv { background: #1d3557; }
.sw.ac { background: #e63946; }
.status { font-size: .92rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PID + saturated actuator simulation. KP/KI/KD are fixed gains; U_MIN/U_MAX model a
// valve or motor that can only push between 0% and 100% output.
const DT = 0.1;
const STEPS = 300;
const SETPOINT = 80;
const KP = 4, KI = 2.5, KD = 0;
const U_MIN = 0, U_MAX = 100;
// First-order lagging plant: dy/dt = (-y + K_PLANT * u) / TAU (Euler-integrated).
const TAU = 3, K_PLANT = 1;
// Runs one full step response, returning the output trace, actuator trace and overshoot.
function simulate(antiwindup) {
let y = 0, integral = 0, prevError = 0;
let maxY = 0;
const ys = [], us = [];
for (let i = 0; i < STEPS; i++) {
const error = SETPOINT - y;
let newIntegral = integral + error * DT;
let uUnsat = KP * error + KI * newIntegral + KD * (error - prevError) / DT;
let uSat = Math.max(U_MIN, Math.min(U_MAX, uUnsat));
if (antiwindup) {
// Integral clamping: freeze the integral only when saturated AND the error would push it further into saturation.
const wouldSaturate = uSat !== uUnsat;
const pushingFurther = (uUnsat > U_MAX && error > 0) || (uUnsat < U_MIN && error < 0);
if (!(wouldSaturate && pushingFurther)) integral = newIntegral;
uUnsat = KP * error + KI * integral + KD * (error - prevError) / DT;
uSat = Math.max(U_MIN, Math.min(U_MAX, uUnsat));
} else {
integral = newIntegral; // No anti-windup — keep integrating no matter what the actuator can actually do.
}
const dydt = (-y + K_PLANT * uSat) / TAU;
y = y + dydt * DT;
prevError = error;
maxY = Math.max(maxY, y);
ys.push(y); us.push(uSat);
}
const overshoot = Math.max(0, ((maxY - SETPOINT) / SETPOINT) * 100);
return { ys, us, overshoot };
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function draw(ys, us) {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const padL = 34, padB = 22, padT = 10, padR = 8;
const plotW = W - padL - padR, plotH = H - padT - padB;
const yMax = 110;
const xOf = i => padL + (i / (STEPS - 1)) * plotW;
const yOf = v => padT + plotH - (v / yMax) * plotH;
// Draw plot axes and gridlines.
ctx.strokeStyle = '#d7dee5'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, padT); ctx.lineTo(padL, padT + plotH); ctx.lineTo(padL + plotW, padT + plotH);
ctx.stroke();
ctx.fillStyle = '#8892a0'; ctx.font = '11px system-ui, sans-serif';
ctx.fillText('100', 2, yOf(100) + 4);
ctx.fillText('0', 18, yOf(0) + 4);
// setpoint line
ctx.strokeStyle = '#94a3b8'; ctx.setLineDash([5, 4]); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(xOf(0), yOf(SETPOINT)); ctx.lineTo(xOf(STEPS - 1), yOf(SETPOINT)); ctx.stroke();
ctx.setLineDash([]);
// actuator command
ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.5;
ctx.beginPath();
us.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
ctx.stroke();
// plant output
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2.2;
ctx.beginPath();
ys.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); });
ctx.stroke();
}
function run() {
const aw = document.getElementById('awToggle').checked;
const { ys, us, overshoot } = simulate(aw);
draw(ys, us);
const s = document.getElementById('status');
if (aw) {
s.textContent = i18nFmt('Anti-windup ON — overshoot: %pct%%. The integral stops accumulating the instant the actuator saturates.', { pct: overshoot.toFixed(1) });
} else {
s.textContent = i18nFmt('Anti-windup OFF — overshoot: %pct%%. The integral kept growing while the actuator was pinned at its limit.', { pct: overshoot.toFixed(1) });
}
console.log('Overshoot over setpoint:', overshoot.toFixed(2) + '%');
}
document.getElementById('run').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
const ctx2 = canvas.getContext('2d');
ctx2.clearRect(0, 0, canvas.width, canvas.height);
document.getElementById('status').textContent = 'Press "Run step" to command a jump in setpoint.';
});
run();
Comments
Loading comments...