Below is a simple sliding mass on a spring. The simulation only measures its position — the velocity is hidden, exactly like a real sensor that can't see it directly. A Luenberger observer runs its own internal model side by side, corrects itself using the position error, and estimates the velocity anyway.
<p class="hint">A mass on a spring oscillates; only its <b>position</b> is measured (gray dots), never its velocity. The observer (red dashed) runs its own internal copy of the system and corrects itself using the position error alone — watch it converge onto the true velocity (dark blue solid) it was never shown.</p>
<canvas id="plot" width="640" height="220"></canvas>
<div class="legend">
<span class="lg lg-true">True velocity</span>
<span class="lg lg-est">Estimated velocity</span>
<span class="lg lg-meas">Position sensor (measured, not plotted as velocity)</span>
</div>
<div class="slider-row">
<label for="gain">Observer gain L</label>
<input type="range" id="gain" min="0" max="120" value="20" step="1">
<span id="gainval" class="gainval">2.0</span>
</div>
<div class="status" id="status">Set a gain and press Run to start the observer from a deliberately wrong guess.</div>
<div class="btns">
<button id="run" type="button">Run</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; }
canvas { width: 100%; max-width: 640px; height: 220px; display: block; background: #f4f6f8;
border: 1px solid #e1e6ea; border-radius: 8px; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .8rem; font-weight: 600; margin: .5rem 0; }
.lg { position: relative; padding-left: 1.3rem; }
.lg::before { content: ""; position: absolute; left: 0; top: 50%; width: 1rem; height: 3px; transform: translateY(-50%); border-radius: 2px; }
.lg-true::before { background: #1d3557; }
.lg-est::before { background: #e63946; }
.lg-meas::before { background: #adb1b8; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin: .6rem 0; font-size: .9rem; }
.slider-row input[type="range"] { flex: 1; }
.gainval { font: 700 14px ui-monospace, monospace; min-width: 3em; text-align: right; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c9962f; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Plant: mass-spring system, state = [position, velocity].
// xdot = A x + B u, with A = [[0,1],[-K/M,-B_DAMP/M]], B = [[0],[1/M]].
// Only position y = x[0] is measured; velocity x[1] is never observed directly.
const M = 1, K = 4, B_DAMP = 0; // kept undamped on purpose: with L = 0 the initial error never dies out, making the observer's correction visibly necessary.
const DT = 0.02, STEPS = 400; // fixed-step Euler integration, 400 steps at dt = 0.02s (8 simulated seconds).
// Observer copies the plant's own dynamics exactly, then adds L*(measured - predicted output).
// xhatdot = A xhat + B u + L*(y - C xhat), where C = [1,0] extracts predicted position.
function deriv(state, u) {
const [pos, vel] = state;
return [vel, (-K / M) * pos + (-B_DAMP / M) * vel + u / M];
}
const gainSlider = document.getElementById('gain');
const gainVal = document.getElementById('gainval');
const canvas = document.getElementById('plot');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
let running = false;
let raf = null;
function gainFromSlider() {
// Slider 0-60 maps to L1 = 0.0-6.0; L2 is fixed at 1.5*L1 for a well-shaped error matrix.
return Number(gainSlider.value) / 10;
}
function updateGainLabel() {
gainVal.textContent = gainFromSlider().toFixed(1);
}
function freshRun() {
// The observer always starts from the same wrong velocity guess so convergence is visible.
const trueState = [1, 0];
const estState = [1, 5]; // true velocity starts at 0, observer's guess starts at 5 - a large deliberate mismatch.
return { trueState, estState, tHist: [], trueVelHist: [], estVelHist: [], measHist: [], step: 0 };
}
let run = freshRun();
function step(run) {
const L1 = gainFromSlider();
const L2 = L1 * 1.5; // L2 = 1.5 * L1 keeps eigenvalues of (A - L C) complex with negative real part across the slider range.
const t = run.step * DT;
const u = 0.5 * Math.sin(0.7 * t);
// Position sensor reading = true position + small pseudo-random noise.
const noise = (Math.sin(run.step * 12.9898) * 43758.5453 % 1) * 0.15;
const y = run.trueState[0] + noise;
const dTrue = deriv(run.trueState, u);
const dEst = deriv(run.estState, u);
const innovation = y - run.estState[0]; // Innovation = measured output minus the observer's own predicted output.
dEst[0] += L1 * innovation;
dEst[1] += L2 * innovation;
run.trueState = [run.trueState[0] + dTrue[0] * DT, run.trueState[1] + dTrue[1] * DT];
run.estState = [run.estState[0] + dEst[0] * DT, run.estState[1] + dEst[1] * DT];
run.tHist.push(t);
run.trueVelHist.push(run.trueState[1]);
run.estVelHist.push(run.estState[1]);
run.measHist.push(y);
run.step++;
}
function draw(run) {
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const n = run.tHist.length;
if (n < 2) return;
// Auto-scale the vertical axis to the largest velocity magnitude seen so far, with 15% headroom.
const allVals = run.trueVelHist.concat(run.estVelHist);
let maxAbs = Math.max(1, ...allVals.map(Math.abs));
maxAbs *= 1.15;
const padL = 36, padR = 10, padT = 10, padB = 20;
const plotW = w - padL - padR, plotH = h - padT - padB;
const xOf = (i) => padL + (i / (STEPS - 1)) * plotW;
const yOf = (v) => padT + plotH / 2 - (v / maxAbs) * (plotH / 2);
// Draw a light horizontal line at velocity = 0 as a visual reference.
ctx.strokeStyle = '#d6dbe0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, yOf(0));
ctx.lineTo(w - padR, yOf(0));
ctx.stroke();
ctx.fillStyle = '#7a8794';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText(maxAbs.toFixed(1), 2, yOf(maxAbs) + 4);
ctx.fillText('0', 2, yOf(0) + 4);
ctx.fillText((-maxAbs).toFixed(1), 2, yOf(-maxAbs) + 4);
function plotLine(hist, color, width, dash) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dash || []);
ctx.beginPath();
hist.forEach((v, i) => {
const x = xOf(i), y = yOf(v);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
}
// Placeholder line kept for symmetry; the noisy measurement only affects position, not this velocity plot directly.
plotLine(run.measHist.map(() => 0), '#00000000', 1, []); // no-op keeps structure symmetric
plotLine(run.trueVelHist, '#1d3557', 2.4, []);
plotLine(run.estVelHist, '#e63946', 2.2, [6, 4]);
}
function currentError(run) {
// Average |velocity error| over the last 25 steps, smoother than a single instantaneous sample.
const n = run.trueVelHist.length;
if (!n) return 0;
const window = Math.min(25, n);
let sum = 0;
for (let i = n - window; i < n; i++) sum += Math.abs(run.trueVelHist[i] - run.estVelHist[i]);
return sum / window;
}
function loop() {
if (!running) return;
step(run);
draw(run);
const err = currentError(run);
const L1 = gainFromSlider();
if (run.step >= STEPS) {
running = false;
runBtn.textContent = 'Run';
if (L1 === 0) {
statusEl.textContent = fmt('No correction applied (L = 0): the wrong initial guess never gets fixed, final error = %e%.', { e: err.toFixed(2) });
statusEl.className = 'status bad';
} else if (err < 0.05) {
statusEl.textContent = fmt('Converged: the estimate settled within %e% of the true velocity — L placed the error dynamics in the stable region.', { e: err.toFixed(3) });
statusEl.className = 'status ok';
} else if (L1 > 5) {
statusEl.textContent = fmt('Converged but jittery: a high gain reacts hard to sensor noise, final error = %e%.', { e: err.toFixed(2) });
statusEl.className = 'status warn';
} else {
statusEl.textContent = fmt('Still settling: final error = %e%. Try a slightly higher gain or let it run longer.', { e: err.toFixed(2) });
statusEl.className = 'status warn';
}
console.log(fmt('L1=%l1% L2=%l2% final |velocity error|=%err%', { l1: L1.toFixed(1), l2: (L1 * 1.5).toFixed(1), err: err.toFixed(4) }));
return;
}
statusEl.textContent = fmt('Running… current |velocity error| = %e%', { e: err.toFixed(2) });
statusEl.className = 'status';
raf = requestAnimationFrame(loop);
}
gainSlider.addEventListener('input', updateGainLabel);
runBtn.addEventListener('click', () => {
if (running) return;
run = freshRun();
running = true;
runBtn.textContent = 'Running…';
statusEl.textContent = 'Starting from a deliberately wrong velocity guess…';
statusEl.className = 'status';
loop();
});
document.getElementById('reset').addEventListener('click', () => {
running = false;
if (raf) cancelAnimationFrame(raf);
run = freshRun();
draw(run);
runBtn.textContent = 'Run';
statusEl.textContent = 'Set a gain and press Run to start the observer from a deliberately wrong guess.';
statusEl.className = 'status';
});
updateGainLabel();
draw(run);
<p class="hint">A mass on a spring oscillates; only its <b>position</b> is measured (gray dots), never its velocity. The observer (red dashed) runs its own internal copy of the system and corrects itself using the position error alone — watch it converge onto the true velocity (dark blue solid) it was never shown.</p>
<canvas id="plot" width="640" height="220"></canvas>
<div class="legend">
<span class="lg lg-true">True velocity</span>
<span class="lg lg-est">Estimated velocity</span>
<span class="lg lg-meas">Position sensor (measured, not plotted as velocity)</span>
</div>
<div class="slider-row">
<label for="gain">Observer gain L</label>
<input type="range" id="gain" min="0" max="120" value="20" step="1">
<span id="gainval" class="gainval">2.0</span>
</div>
<div class="status" id="status">Set a gain and press Run to start the observer from a deliberately wrong guess.</div>
<div class="btns">
<button id="run" type="button">Run</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; }
canvas { width: 100%; max-width: 640px; height: 220px; display: block; background: #f4f6f8;
border: 1px solid #e1e6ea; border-radius: 8px; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .8rem; font-weight: 600; margin: .5rem 0; }
.lg { position: relative; padding-left: 1.3rem; }
.lg::before { content: ""; position: absolute; left: 0; top: 50%; width: 1rem; height: 3px; transform: translateY(-50%); border-radius: 2px; }
.lg-true::before { background: #1d3557; }
.lg-est::before { background: #e63946; }
.lg-meas::before { background: #adb1b8; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin: .6rem 0; font-size: .9rem; }
.slider-row input[type="range"] { flex: 1; }
.gainval { font: 700 14px ui-monospace, monospace; min-width: 3em; text-align: right; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c9962f; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Plant: mass-spring system, state = [position, velocity].
// xdot = A x + B u, with A = [[0,1],[-K/M,-B_DAMP/M]], B = [[0],[1/M]].
// Only position y = x[0] is measured; velocity x[1] is never observed directly.
const M = 1, K = 4, B_DAMP = 0; // kept undamped on purpose: with L = 0 the initial error never dies out, making the observer's correction visibly necessary.
const DT = 0.02, STEPS = 400; // fixed-step Euler integration, 400 steps at dt = 0.02s (8 simulated seconds).
// Observer copies the plant's own dynamics exactly, then adds L*(measured - predicted output).
// xhatdot = A xhat + B u + L*(y - C xhat), where C = [1,0] extracts predicted position.
function deriv(state, u) {
const [pos, vel] = state;
return [vel, (-K / M) * pos + (-B_DAMP / M) * vel + u / M];
}
const gainSlider = document.getElementById('gain');
const gainVal = document.getElementById('gainval');
const canvas = document.getElementById('plot');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
let running = false;
let raf = null;
function gainFromSlider() {
// Slider 0-60 maps to L1 = 0.0-6.0; L2 is fixed at 1.5*L1 for a well-shaped error matrix.
return Number(gainSlider.value) / 10;
}
function updateGainLabel() {
gainVal.textContent = gainFromSlider().toFixed(1);
}
function freshRun() {
// The observer always starts from the same wrong velocity guess so convergence is visible.
const trueState = [1, 0];
const estState = [1, 5]; // true velocity starts at 0, observer's guess starts at 5 - a large deliberate mismatch.
return { trueState, estState, tHist: [], trueVelHist: [], estVelHist: [], measHist: [], step: 0 };
}
let run = freshRun();
function step(run) {
const L1 = gainFromSlider();
const L2 = L1 * 1.5; // L2 = 1.5 * L1 keeps eigenvalues of (A - L C) complex with negative real part across the slider range.
const t = run.step * DT;
const u = 0.5 * Math.sin(0.7 * t);
// Position sensor reading = true position + small pseudo-random noise.
const noise = (Math.sin(run.step * 12.9898) * 43758.5453 % 1) * 0.15;
const y = run.trueState[0] + noise;
const dTrue = deriv(run.trueState, u);
const dEst = deriv(run.estState, u);
const innovation = y - run.estState[0]; // Innovation = measured output minus the observer's own predicted output.
dEst[0] += L1 * innovation;
dEst[1] += L2 * innovation;
run.trueState = [run.trueState[0] + dTrue[0] * DT, run.trueState[1] + dTrue[1] * DT];
run.estState = [run.estState[0] + dEst[0] * DT, run.estState[1] + dEst[1] * DT];
run.tHist.push(t);
run.trueVelHist.push(run.trueState[1]);
run.estVelHist.push(run.estState[1]);
run.measHist.push(y);
run.step++;
}
function draw(run) {
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const n = run.tHist.length;
if (n < 2) return;
// Auto-scale the vertical axis to the largest velocity magnitude seen so far, with 15% headroom.
const allVals = run.trueVelHist.concat(run.estVelHist);
let maxAbs = Math.max(1, ...allVals.map(Math.abs));
maxAbs *= 1.15;
const padL = 36, padR = 10, padT = 10, padB = 20;
const plotW = w - padL - padR, plotH = h - padT - padB;
const xOf = (i) => padL + (i / (STEPS - 1)) * plotW;
const yOf = (v) => padT + plotH / 2 - (v / maxAbs) * (plotH / 2);
// Draw a light horizontal line at velocity = 0 as a visual reference.
ctx.strokeStyle = '#d6dbe0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, yOf(0));
ctx.lineTo(w - padR, yOf(0));
ctx.stroke();
ctx.fillStyle = '#7a8794';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText(maxAbs.toFixed(1), 2, yOf(maxAbs) + 4);
ctx.fillText('0', 2, yOf(0) + 4);
ctx.fillText((-maxAbs).toFixed(1), 2, yOf(-maxAbs) + 4);
function plotLine(hist, color, width, dash) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dash || []);
ctx.beginPath();
hist.forEach((v, i) => {
const x = xOf(i), y = yOf(v);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
}
// Placeholder line kept for symmetry; the noisy measurement only affects position, not this velocity plot directly.
plotLine(run.measHist.map(() => 0), '#00000000', 1, []); // no-op keeps structure symmetric
plotLine(run.trueVelHist, '#1d3557', 2.4, []);
plotLine(run.estVelHist, '#e63946', 2.2, [6, 4]);
}
function currentError(run) {
// Average |velocity error| over the last 25 steps, smoother than a single instantaneous sample.
const n = run.trueVelHist.length;
if (!n) return 0;
const window = Math.min(25, n);
let sum = 0;
for (let i = n - window; i < n; i++) sum += Math.abs(run.trueVelHist[i] - run.estVelHist[i]);
return sum / window;
}
function loop() {
if (!running) return;
step(run);
draw(run);
const err = currentError(run);
const L1 = gainFromSlider();
if (run.step >= STEPS) {
running = false;
runBtn.textContent = 'Run';
if (L1 === 0) {
statusEl.textContent = fmt('No correction applied (L = 0): the wrong initial guess never gets fixed, final error = %e%.', { e: err.toFixed(2) });
statusEl.className = 'status bad';
} else if (err < 0.05) {
statusEl.textContent = fmt('Converged: the estimate settled within %e% of the true velocity — L placed the error dynamics in the stable region.', { e: err.toFixed(3) });
statusEl.className = 'status ok';
} else if (L1 > 5) {
statusEl.textContent = fmt('Converged but jittery: a high gain reacts hard to sensor noise, final error = %e%.', { e: err.toFixed(2) });
statusEl.className = 'status warn';
} else {
statusEl.textContent = fmt('Still settling: final error = %e%. Try a slightly higher gain or let it run longer.', { e: err.toFixed(2) });
statusEl.className = 'status warn';
}
console.log(fmt('L1=%l1% L2=%l2% final |velocity error|=%err%', { l1: L1.toFixed(1), l2: (L1 * 1.5).toFixed(1), err: err.toFixed(4) }));
return;
}
statusEl.textContent = fmt('Running… current |velocity error| = %e%', { e: err.toFixed(2) });
statusEl.className = 'status';
raf = requestAnimationFrame(loop);
}
gainSlider.addEventListener('input', updateGainLabel);
runBtn.addEventListener('click', () => {
if (running) return;
run = freshRun();
running = true;
runBtn.textContent = 'Running…';
statusEl.textContent = 'Starting from a deliberately wrong velocity guess…';
statusEl.className = 'status';
loop();
});
document.getElementById('reset').addEventListener('click', () => {
running = false;
if (raf) cancelAnimationFrame(raf);
run = freshRun();
draw(run);
runBtn.textContent = 'Run';
statusEl.textContent = 'Set a gain and press Run to start the observer from a deliberately wrong guess.';
statusEl.className = 'status';
});
updateGainLabel();
draw(run);
Comments
Loading comments...