Below is a simulated mass on a spring-damper track. Click Step setpoint to ask it to move, and watch the controller chase it. Each gain does one job:
<p class="hint">A simulated mass on a spring-and-damper track must reach the dashed setpoint line. Drag the sliders to change the P, I and D gains, then press <b>Step setpoint</b> to move the target and watch the blue trace chase it.</p>
<canvas id="scope" class="scope" width="640" height="220"></canvas>
<div class="readout" id="readout">Adjust the gains, then step the setpoint.</div>
<div class="sliders">
<label>P (proportional) <span id="pVal">4.0</span>
<input id="pSlider" type="range" min="0" max="12" step="0.1" value="4">
</label>
<label>I (integral) <span id="iVal">0.0</span>
<input id="iSlider" type="range" min="0" max="10" step="0.1" value="0">
</label>
<label>D (derivative) <span id="dVal">0.0</span>
<input id="dSlider" type="range" min="0" max="5" step="0.1" value="0">
</label>
</div>
<div class="btns">
<button id="step" type="button">Step setpoint</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; }
.scope { width: 100%; max-width: 640px; height: 180px; background: #f4f6f8; border: 1px solid #cdd9e3;
border-radius: 8px; display: block; }
.readout { font: 600 .95rem ui-monospace, monospace; color: #1d3557; margin: .5rem 0; min-height: 1.4em; }
.sliders { display: flex; flex-direction: column; gap: .5rem; margin: .6rem 0; }
.sliders label { font-size: .85rem; font-weight: 600; color: #1d3557; display: flex; flex-direction: column; gap: .2rem; }
.sliders input[type="range"] { width: 100%; accent-color: #1d3557; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated plant: a mass on a spring with damping, m*x'' + b*x' + k*x = u(t).
// The controller only ever sees the error; it never sees m, b or k directly.
const MASS = 1, DAMP = 1.2, STIFF = 2;
const DT = 0.02; // simulation time step in seconds
const STEPS = 700; // total simulated steps per run
const BAND = 0.02; // "settled" tolerance band, as a fraction of the setpoint
let setpoint = 1;
let history = []; // not used directly; each run recomputes its own path
let running = false;
const pSlider = document.getElementById('pSlider');
const iSlider = document.getElementById('iSlider');
const dSlider = document.getElementById('dSlider');
const pVal = document.getElementById('pVal');
const iVal = document.getElementById('iVal');
const dVal = document.getElementById('dVal');
const canvas = document.getElementById('scope');
const ctx = canvas.getContext('2d');
const readout = document.getElementById('readout');
function gains(){ return { Kp: parseFloat(pSlider.value), Ki: parseFloat(iSlider.value), Kd: parseFloat(dSlider.value) }; }
// Runs the discrete PID loop against the mass-spring-damper plant and records the trace.
function simulate(Kp, Ki, Kd, target){
let x = 0, v = 0, integral = 0, prevError = null;
let maxOvershoot = 0, settleStep = null;
const path = [];
for (let i = 0; i < STEPS; i++){
const error = target - x;
integral += error * DT;
const derivative = prevError === null ? 0 : (error - prevError) / DT;
prevError = error;
const u = Kp * error + Ki * integral + Kd * derivative;
const a = (u - DAMP * v - STIFF * x) / MASS; // Newton's second law solved for acceleration: a = (u - damping - spring)/mass.
v += a * DT;
x += v * DT;
path.push(x);
if (target !== 0){
const overshoot = (x - target) / target;
if (overshoot > maxOvershoot) maxOvershoot = overshoot;
}
if (settleStep === null && Math.abs(x - target) < BAND * Math.max(Math.abs(target), 1)){
settleStep = i;
}
}
return { path, maxOvershoot, settleStep };
}
function draw(path, target){
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
// light horizontal grid lines for readability
ctx.strokeStyle = '#dbe3ea';
ctx.lineWidth = 1;
for (let gy = 0; gy <= 4; gy++){
const y = (h / 4) * gy;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
const yMax = Math.max(1.6, target * 1.6);
const toY = (val) => h - (val / yMax) * h;
// dashed red line marking the target the controller is chasing
ctx.strokeStyle = '#e63946';
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(0, toY(target));
ctx.lineTo(w, toY(target));
ctx.stroke();
ctx.setLineDash([]);
// solid blue line tracing the simulated position over time
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
path.forEach((val, i) => {
const px = (i / (path.length - 1)) * w;
const py = toY(val);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.stroke();
}
function run(){
const { Kp, Ki, Kd } = gains();
const { path, maxOvershoot, settleStep } = simulate(Kp, Ki, Kd, setpoint);
draw(path, setpoint);
const overshootPct = Math.max(0, maxOvershoot * 100);
if (settleStep === null){
readout.textContent = fmt('Did not settle within the time window (peak overshoot %overshoot%%).', { overshoot: overshootPct.toFixed(0) });
} else {
const settleTime = (settleStep * DT).toFixed(2);
readout.textContent = fmt('Settled in %time% s with %overshoot%% overshoot.', { time: settleTime, overshoot: overshootPct.toFixed(0) });
}
}
pSlider.addEventListener('input', () => { pVal.textContent = parseFloat(pSlider.value).toFixed(1); run(); });
iSlider.addEventListener('input', () => { iVal.textContent = parseFloat(iSlider.value).toFixed(1); run(); });
dSlider.addEventListener('input', () => { dVal.textContent = parseFloat(dSlider.value).toFixed(1); run(); });
document.getElementById('step').addEventListener('click', () => {
setpoint = setpoint === 1 ? 1.6 : 1; // alternate between two setpoints so the controller has something to chase
run();
});
document.getElementById('reset').addEventListener('click', () => {
pSlider.value = 4; iSlider.value = 0; dSlider.value = 0;
pVal.textContent = '4.0'; iVal.textContent = '0.0'; dVal.textContent = '0.0';
setpoint = 1;
run();
});
readout.textContent = 'Adjust the gains, then step the setpoint.';
run();
<p class="hint">A simulated mass on a spring-and-damper track must reach the dashed setpoint line. Drag the sliders to change the P, I and D gains, then press <b>Step setpoint</b> to move the target and watch the blue trace chase it.</p>
<canvas id="scope" class="scope" width="640" height="220"></canvas>
<div class="readout" id="readout">Adjust the gains, then step the setpoint.</div>
<div class="sliders">
<label>P (proportional) <span id="pVal">4.0</span>
<input id="pSlider" type="range" min="0" max="12" step="0.1" value="4">
</label>
<label>I (integral) <span id="iVal">0.0</span>
<input id="iSlider" type="range" min="0" max="10" step="0.1" value="0">
</label>
<label>D (derivative) <span id="dVal">0.0</span>
<input id="dSlider" type="range" min="0" max="5" step="0.1" value="0">
</label>
</div>
<div class="btns">
<button id="step" type="button">Step setpoint</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; }
.scope { width: 100%; max-width: 640px; height: 180px; background: #f4f6f8; border: 1px solid #cdd9e3;
border-radius: 8px; display: block; }
.readout { font: 600 .95rem ui-monospace, monospace; color: #1d3557; margin: .5rem 0; min-height: 1.4em; }
.sliders { display: flex; flex-direction: column; gap: .5rem; margin: .6rem 0; }
.sliders label { font-size: .85rem; font-weight: 600; color: #1d3557; display: flex; flex-direction: column; gap: .2rem; }
.sliders input[type="range"] { width: 100%; accent-color: #1d3557; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated plant: a mass on a spring with damping, m*x'' + b*x' + k*x = u(t).
// The controller only ever sees the error; it never sees m, b or k directly.
const MASS = 1, DAMP = 1.2, STIFF = 2;
const DT = 0.02; // simulation time step in seconds
const STEPS = 700; // total simulated steps per run
const BAND = 0.02; // "settled" tolerance band, as a fraction of the setpoint
let setpoint = 1;
let history = []; // not used directly; each run recomputes its own path
let running = false;
const pSlider = document.getElementById('pSlider');
const iSlider = document.getElementById('iSlider');
const dSlider = document.getElementById('dSlider');
const pVal = document.getElementById('pVal');
const iVal = document.getElementById('iVal');
const dVal = document.getElementById('dVal');
const canvas = document.getElementById('scope');
const ctx = canvas.getContext('2d');
const readout = document.getElementById('readout');
function gains(){ return { Kp: parseFloat(pSlider.value), Ki: parseFloat(iSlider.value), Kd: parseFloat(dSlider.value) }; }
// Runs the discrete PID loop against the mass-spring-damper plant and records the trace.
function simulate(Kp, Ki, Kd, target){
let x = 0, v = 0, integral = 0, prevError = null;
let maxOvershoot = 0, settleStep = null;
const path = [];
for (let i = 0; i < STEPS; i++){
const error = target - x;
integral += error * DT;
const derivative = prevError === null ? 0 : (error - prevError) / DT;
prevError = error;
const u = Kp * error + Ki * integral + Kd * derivative;
const a = (u - DAMP * v - STIFF * x) / MASS; // Newton's second law solved for acceleration: a = (u - damping - spring)/mass.
v += a * DT;
x += v * DT;
path.push(x);
if (target !== 0){
const overshoot = (x - target) / target;
if (overshoot > maxOvershoot) maxOvershoot = overshoot;
}
if (settleStep === null && Math.abs(x - target) < BAND * Math.max(Math.abs(target), 1)){
settleStep = i;
}
}
return { path, maxOvershoot, settleStep };
}
function draw(path, target){
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
// light horizontal grid lines for readability
ctx.strokeStyle = '#dbe3ea';
ctx.lineWidth = 1;
for (let gy = 0; gy <= 4; gy++){
const y = (h / 4) * gy;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
const yMax = Math.max(1.6, target * 1.6);
const toY = (val) => h - (val / yMax) * h;
// dashed red line marking the target the controller is chasing
ctx.strokeStyle = '#e63946';
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(0, toY(target));
ctx.lineTo(w, toY(target));
ctx.stroke();
ctx.setLineDash([]);
// solid blue line tracing the simulated position over time
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2;
ctx.beginPath();
path.forEach((val, i) => {
const px = (i / (path.length - 1)) * w;
const py = toY(val);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.stroke();
}
function run(){
const { Kp, Ki, Kd } = gains();
const { path, maxOvershoot, settleStep } = simulate(Kp, Ki, Kd, setpoint);
draw(path, setpoint);
const overshootPct = Math.max(0, maxOvershoot * 100);
if (settleStep === null){
readout.textContent = fmt('Did not settle within the time window (peak overshoot %overshoot%%).', { overshoot: overshootPct.toFixed(0) });
} else {
const settleTime = (settleStep * DT).toFixed(2);
readout.textContent = fmt('Settled in %time% s with %overshoot%% overshoot.', { time: settleTime, overshoot: overshootPct.toFixed(0) });
}
}
pSlider.addEventListener('input', () => { pVal.textContent = parseFloat(pSlider.value).toFixed(1); run(); });
iSlider.addEventListener('input', () => { iVal.textContent = parseFloat(iSlider.value).toFixed(1); run(); });
dSlider.addEventListener('input', () => { dVal.textContent = parseFloat(dSlider.value).toFixed(1); run(); });
document.getElementById('step').addEventListener('click', () => {
setpoint = setpoint === 1 ? 1.6 : 1; // alternate between two setpoints so the controller has something to chase
run();
});
document.getElementById('reset').addEventListener('click', () => {
pSlider.value = 4; iSlider.value = 0; dSlider.value = 0;
pVal.textContent = '4.0'; iVal.textContent = '0.0'; dVal.textContent = '0.0';
setpoint = 1;
run();
});
readout.textContent = 'Adjust the gains, then step the setpoint.';
run();
P (proportional) pushes harder the further you are from target — crank it up and the response gets fast but jittery. I (integral) remembers the accumulated past error and erases any leftover offset — push it too far and it overshoots and rings. D (derivative) reacts to how fast the error is changing, damping the swing before it happens. Try P alone first, then add I, then add D, and watch the settle time and overshoot trade off against each other.
Comments
Loading comments...