Introduction

A drone knows its motor commands and can read its altitude, but nobody bolted a sensor onto its vertical velocity. A chemical reactor reports temperature at one probe, yet the concentrations racing through its interior stay invisible. In both cases, the quantity you actually need for control is not the one your sensors hand you.

In 1964, the engineer David Luenberger asked a deceptively simple question: if you know exactly how a system evolves — its equations, its inputs — can you compute the state you never measured, just by watching how wrong your guess turns out to be?

His answer was the Luenberger observer: run a simulated copy of the real system inside a computer, compare its predicted output to the real sensor reading, and feed that mismatch back in to correct the simulation. Get the correction right, and the copy's internal state converges onto the true, hidden state of the real system — even though that state was never measured directly.

Watch the Estimate Converge

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);
Result
Console

Set the observer gain L and press Run. With L = 0 the observer is just an open-loop simulation — any mismatch in its starting guess persists forever. Raise L, and the estimated velocity (dashed) bends toward the true, hidden velocity (solid) even though velocity is never fed to the observer directly. Push L too high and you'll see the estimate start to jitter — the correction reacts so hard to sensor noise that it starts fighting itself.

The Real Complexity

The observer looks almost too simple to trust: copy the system, correct it, done. The real content is in why the correction works, and how well.

  • The setup. The real system evolves as x˙=Ax+Bu\dot{x} = Ax + Bu, but you only measure y=Cxy = Cx. The observer keeps its own estimate x^\hat{x} and updates it with x^˙=Ax^+Bu+L(y−Cx^)\dot{\hat{x}} = A\hat{x} + Bu + L(y - C\hat{x}) — the same dynamics as the real system, plus a correction term driven by the output error y−Cx^y - C\hat{x}.
  • The error equation is the whole story. Define e=x−x^e = x - \hat{x}. Subtracting the two equations above, every term involving the known input uu cancels, leaving e˙=(A−LC)e\dot{e} = (A - LC)e. The estimation error obeys its own linear dynamics, completely decoupled from what the system is actually doing.
  • Convergence is a choice, not a hope. Because e˙=(A−LC)e\dot{e} = (A - LC)e, the error decays to zero whenever the eigenvalues of A−LCA - LC have negative real part — and you get to choose LL. If the pair (A,C)(A, C) is observable (see controllability and observability), a theorem guarantees you can place those eigenvalues anywhere you like by picking LL appropriately, exactly mirroring how state feedback places the eigenvalues of A−BKA - BK.
  • Speed versus noise. Pushing the eigenvalues further left makes e→0e \to 0 faster, but every real sensor carries noise, and a larger LL amplifies how hard that noise shakes the estimate. Choosing LL is a genuine trade-off between how fast you trust the model and how much you trust the measurement — the same trade-off the Kalman filter resolves optimally under known noise statistics.

So the "real complexity" here is not computational hardness — it's a clean, guaranteed exponential convergence, with a dial you can turn to trade speed for robustness.

Where It Matters

Almost every real control law is designed assuming you know the full state xx, yet almost no real system measures all of it. The observer is the bridge between that assumption and reality:

  • Output feedback control: pair an observer with a state-feedback controller — the celebrated separation principle says you can design each independently, and the combination is still stable.
  • Sensorless motor drives: many electric motors estimate rotor position and speed from voltage and current alone, skipping an expensive physical sensor entirely.
  • Aerospace and robotics: aircraft and drones reconstruct velocities and unmeasured angular rates between the readings of cheaper, noisier sensors.
  • Fault detection: comparing the real output to what an observer predicts is a standard way to flag that a sensor or actuator has failed — a large output error signals something the model didn't expect.

The observer's cousin, the Kalman filter, adds an optimal answer to "what if the model and sensors are noisy" — but the deterministic Luenberger structure at its core is exactly this same feedback-corrected copy.

Conclusion

The Luenberger observer is a small piece of mathematical elegance: take a system you cannot fully see into, run a copy of it in software, and let the error between prediction and measurement do the work of correction. As long as the system is observable, that error is guaranteed to vanish, and with it the gap between your estimate and reality.

It is a reminder that "unmeasured" does not mean "unknowable" — sometimes the missing state is hiding in plain sight, encoded in how the outputs you do have keep changing.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/luenberger-observer/Content licensed under CC BY-NC 4.0.