Below, a pendulum swings under gravity and friction — a genuinely nonlinear system, since its acceleration depends on sin(θ). The gray arm shows the true motion. Only a noisy reading of the angle is available at every step (the scattered red marks) — the angular velocity is never measured directly.
<p class="hint">A pendulum swings under gravity — a nonlinear system, since its acceleration depends on sin(angle). Only noisy angle readings (red dots) are available. Watch the EKF (blue) recover the true swing (gray) by re-linearizing the dynamics at every step.</p>
<div class="stage">
<svg id="pendulum-svg" viewBox="0 0 300 220" class="pend-svg">
<line x1="150" y1="20" x2="150" y2="26" stroke="#8a94a3" stroke-width="2"/>
<circle cx="150" cy="20" r="4" fill="#8a94a3"/>
<line id="arm-true" x1="150" y1="20" x2="150" y2="150" class="arm true-arm"/>
<circle id="bob-true" cx="150" cy="150" r="7" class="bob true-bob"/>
<line id="arm-est" x1="150" y1="20" x2="150" y2="150" class="arm est-arm"/>
<circle id="bob-est" cx="150" cy="150" r="7" class="bob est-bob"/>
<g id="meas-marks"></g>
</svg>
<div class="legend">
<span><i class="swatch true-swatch"></i>True angle</span>
<span><i class="swatch meas-swatch"></i>Noisy reading</span>
<span><i class="swatch est-swatch"></i>EKF estimate</span>
</div>
</div>
<div class="status" id="status">Press Run to start the pendulum and the filter.</div>
<div class="btns">
<button id="run" type="button">Run</button>
<button id="disturb" type="button">Add Disturbance</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 .6rem; line-height: 1.45; }
.stage { display: flex; flex-direction: column; align-items: center; }
.pend-svg { width: 260px; height: 190px; background: #f4f6f8; border-radius: 10px; }
.arm { stroke-width: 3; stroke-linecap: round; }
.true-arm { stroke: #9aa3af; }
.est-arm { stroke: #1d3557; }
.bob { stroke: #fff; stroke-width: 1.5; }
.true-bob { fill: #9aa3af; }
.est-bob { fill: #1d3557; }
.meas-tick { fill: #e63946; opacity: .85; }
.legend { display: flex; gap: 1rem; font-size: .78rem; color: #444; margin: .35rem 0 .2rem; flex-wrap: wrap; justify-content: center; }
.legend span { display: flex; align-items: center; gap: .3rem; }
.swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.true-swatch { background: #9aa3af; }
.meas-swatch { background: #e63946; }
.est-swatch { background: #1d3557; }
.status { font-size: .95rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; text-align: center; }
.status.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
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: .55; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Pendulum model (nonlinear): theta' = omega, omega' = -(g/L) sin(theta) - b*omega.
// Only theta is measured, with additive Gaussian noise of variance R.
const G = 9.81, LEN = 1.0, DAMP = 0.4;
const DT = 0.033;
const Q = [[0.001, 0], [0, 0.01]]; // process noise covariance, added every predict step
const R = 0.06; // measurement noise variance on the observed angle
const PIVOT_X = 150, PIVOT_Y = 20, ARM_PX = 130;
// True nonlinear dynamics f(state) and one Euler integration step.
function derivative(state) {
const [theta, omega] = state;
const alpha = -(G / LEN) * Math.sin(theta) - DAMP * omega;
return [omega, alpha];
}
function stepState(state, dt) {
const d = derivative(state);
return [state[0] + d[0] * dt, state[1] + d[1] * dt];
}
// Jacobian of f at the current estimate: F = d(f)/d(state), recomputed every step.
function jacobianF(state, dt) {
const [theta] = state;
return [
[1, dt],
[-(G / LEN) * Math.cos(theta) * dt, 1 - DAMP * dt]
];
}
function matMul2(A, B) {
const r = [[0, 0], [0, 0]];
for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) for (let k = 0; k < 2; k++) r[i][j] += A[i][k] * B[k][j];
return r;
}
function transpose2(A) { return [[A[0][0], A[1][0]], [A[0][1], A[1][1]]]; }
function add2(A, B) { return [[A[0][0] + B[0][0], A[0][1] + B[0][1]], [A[1][0] + B[1][0], A[1][1] + B[1][1]]]; }
function randn() {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
let trueState = [1.0, 0]; // [theta, omega] of the real, simulated pendulum
let est = [0.2, 0]; // [theta, omega] estimated by the EKF, deliberately started off from the truth
let P = [[1, 0], [0, 1]]; // EKF state covariance matrix
let running = false;
let timer = null;
let stepCount = 0;
let sumSqErr = 0, sumSqMeas = 0, errSamples = 0;
const measHistory = [];
function toXY(theta) {
return [PIVOT_X + ARM_PX * Math.sin(theta), PIVOT_Y + ARM_PX * Math.cos(theta)];
}
function render(lastMeasTheta) {
const [txT, tyT] = toXY(trueState[0]);
document.getElementById('arm-true').setAttribute('x2', txT);
document.getElementById('arm-true').setAttribute('y2', tyT);
document.getElementById('bob-true').setAttribute('cx', txT);
document.getElementById('bob-true').setAttribute('cy', tyT);
const [txE, tyE] = toXY(est[0]);
document.getElementById('arm-est').setAttribute('x2', txE);
document.getElementById('arm-est').setAttribute('y2', tyE);
document.getElementById('bob-est').setAttribute('cx', txE);
document.getElementById('bob-est').setAttribute('cy', tyE);
if (lastMeasTheta !== undefined) {
const [mx, my] = toXY(lastMeasTheta);
measHistory.push([mx, my]);
if (measHistory.length > 12) measHistory.shift();
const g = document.getElementById('meas-marks');
g.innerHTML = '';
measHistory.forEach(([x, y], idx) => {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', x); c.setAttribute('cy', y); c.setAttribute('r', 2.6);
c.setAttribute('class', 'meas-tick');
c.setAttribute('opacity', String(0.15 + 0.7 * (idx / measHistory.length)));
g.appendChild(c);
});
}
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + (cls || '');
}
// One predict-update cycle of the Extended Kalman Filter.
function ekfStep() {
trueState = stepState(trueState, DT);
const z = trueState[0] + Math.sqrt(R) * randn();
const F = jacobianF(est, DT);
est = stepState(est, DT);
P = add2(matMul2(matMul2(F, P), transpose2(F)), Q);
const S = P[0][0] + R; // innovation covariance S = H P H^T + R, with H = [1, 0] since only theta is observed
const K = [P[0][0] / S, P[1][0] / S]; // Kalman gain K = P H^T / S, computed from the linearized model
const y = z - est[0];
est = [est[0] + K[0] * y, est[1] + K[1] * y];
const ImKH = [[1 - K[0], 0], [-K[1], 1]];
P = matMul2(ImKH, P);
stepCount++;
if (stepCount > 20) {
sumSqErr += (est[0] - trueState[0]) ** 2;
sumSqMeas += (z - trueState[0]) ** 2;
errSamples++;
}
render(z);
if (errSamples > 5) {
const rmseEst = Math.sqrt(sumSqErr / errSamples);
const rmseMeas = Math.sqrt(sumSqMeas / errSamples);
setStatus(i18nFmt('EKF RMSE: %rmseEst% rad · raw-reading RMSE: %rmseMeas% rad', { rmseEst: rmseEst.toFixed(3), rmseMeas: rmseMeas.toFixed(3) }), 'ok');
}
}
function run() {
if (running) return;
running = true;
document.getElementById('run').textContent = 'Pause';
timer = setInterval(ekfStep, DT * 1000);
}
function pause() {
running = false;
document.getElementById('run').textContent = 'Run';
if (timer) clearInterval(timer);
}
function reset() {
pause();
trueState = [1.0, 0];
est = [0.2, 0];
P = [[1, 0], [0, 1]];
stepCount = 0;
sumSqErr = 0; sumSqMeas = 0; errSamples = 0;
measHistory.length = 0;
document.getElementById('meas-marks').innerHTML = '';
render();
setStatus('Press Run to start the pendulum and the filter.', '');
}
document.getElementById('run').addEventListener('click', () => { running ? pause() : run(); });
document.getElementById('disturb').addEventListener('click', () => {
trueState[1] += 3.2; // manually inject angular velocity to simulate an external push
setStatus('Disturbance applied — watch the EKF catch up.', '');
});
document.getElementById('reset').addEventListener('click', reset);
render();
<p class="hint">A pendulum swings under gravity — a nonlinear system, since its acceleration depends on sin(angle). Only noisy angle readings (red dots) are available. Watch the EKF (blue) recover the true swing (gray) by re-linearizing the dynamics at every step.</p>
<div class="stage">
<svg id="pendulum-svg" viewBox="0 0 300 220" class="pend-svg">
<line x1="150" y1="20" x2="150" y2="26" stroke="#8a94a3" stroke-width="2"/>
<circle cx="150" cy="20" r="4" fill="#8a94a3"/>
<line id="arm-true" x1="150" y1="20" x2="150" y2="150" class="arm true-arm"/>
<circle id="bob-true" cx="150" cy="150" r="7" class="bob true-bob"/>
<line id="arm-est" x1="150" y1="20" x2="150" y2="150" class="arm est-arm"/>
<circle id="bob-est" cx="150" cy="150" r="7" class="bob est-bob"/>
<g id="meas-marks"></g>
</svg>
<div class="legend">
<span><i class="swatch true-swatch"></i>True angle</span>
<span><i class="swatch meas-swatch"></i>Noisy reading</span>
<span><i class="swatch est-swatch"></i>EKF estimate</span>
</div>
</div>
<div class="status" id="status">Press Run to start the pendulum and the filter.</div>
<div class="btns">
<button id="run" type="button">Run</button>
<button id="disturb" type="button">Add Disturbance</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 .6rem; line-height: 1.45; }
.stage { display: flex; flex-direction: column; align-items: center; }
.pend-svg { width: 260px; height: 190px; background: #f4f6f8; border-radius: 10px; }
.arm { stroke-width: 3; stroke-linecap: round; }
.true-arm { stroke: #9aa3af; }
.est-arm { stroke: #1d3557; }
.bob { stroke: #fff; stroke-width: 1.5; }
.true-bob { fill: #9aa3af; }
.est-bob { fill: #1d3557; }
.meas-tick { fill: #e63946; opacity: .85; }
.legend { display: flex; gap: 1rem; font-size: .78rem; color: #444; margin: .35rem 0 .2rem; flex-wrap: wrap; justify-content: center; }
.legend span { display: flex; align-items: center; gap: .3rem; }
.swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.true-swatch { background: #9aa3af; }
.meas-swatch { background: #e63946; }
.est-swatch { background: #1d3557; }
.status { font-size: .95rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; text-align: center; }
.status.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
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: .55; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Pendulum model (nonlinear): theta' = omega, omega' = -(g/L) sin(theta) - b*omega.
// Only theta is measured, with additive Gaussian noise of variance R.
const G = 9.81, LEN = 1.0, DAMP = 0.4;
const DT = 0.033;
const Q = [[0.001, 0], [0, 0.01]]; // process noise covariance, added every predict step
const R = 0.06; // measurement noise variance on the observed angle
const PIVOT_X = 150, PIVOT_Y = 20, ARM_PX = 130;
// True nonlinear dynamics f(state) and one Euler integration step.
function derivative(state) {
const [theta, omega] = state;
const alpha = -(G / LEN) * Math.sin(theta) - DAMP * omega;
return [omega, alpha];
}
function stepState(state, dt) {
const d = derivative(state);
return [state[0] + d[0] * dt, state[1] + d[1] * dt];
}
// Jacobian of f at the current estimate: F = d(f)/d(state), recomputed every step.
function jacobianF(state, dt) {
const [theta] = state;
return [
[1, dt],
[-(G / LEN) * Math.cos(theta) * dt, 1 - DAMP * dt]
];
}
function matMul2(A, B) {
const r = [[0, 0], [0, 0]];
for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) for (let k = 0; k < 2; k++) r[i][j] += A[i][k] * B[k][j];
return r;
}
function transpose2(A) { return [[A[0][0], A[1][0]], [A[0][1], A[1][1]]]; }
function add2(A, B) { return [[A[0][0] + B[0][0], A[0][1] + B[0][1]], [A[1][0] + B[1][0], A[1][1] + B[1][1]]]; }
function randn() {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
let trueState = [1.0, 0]; // [theta, omega] of the real, simulated pendulum
let est = [0.2, 0]; // [theta, omega] estimated by the EKF, deliberately started off from the truth
let P = [[1, 0], [0, 1]]; // EKF state covariance matrix
let running = false;
let timer = null;
let stepCount = 0;
let sumSqErr = 0, sumSqMeas = 0, errSamples = 0;
const measHistory = [];
function toXY(theta) {
return [PIVOT_X + ARM_PX * Math.sin(theta), PIVOT_Y + ARM_PX * Math.cos(theta)];
}
function render(lastMeasTheta) {
const [txT, tyT] = toXY(trueState[0]);
document.getElementById('arm-true').setAttribute('x2', txT);
document.getElementById('arm-true').setAttribute('y2', tyT);
document.getElementById('bob-true').setAttribute('cx', txT);
document.getElementById('bob-true').setAttribute('cy', tyT);
const [txE, tyE] = toXY(est[0]);
document.getElementById('arm-est').setAttribute('x2', txE);
document.getElementById('arm-est').setAttribute('y2', tyE);
document.getElementById('bob-est').setAttribute('cx', txE);
document.getElementById('bob-est').setAttribute('cy', tyE);
if (lastMeasTheta !== undefined) {
const [mx, my] = toXY(lastMeasTheta);
measHistory.push([mx, my]);
if (measHistory.length > 12) measHistory.shift();
const g = document.getElementById('meas-marks');
g.innerHTML = '';
measHistory.forEach(([x, y], idx) => {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', x); c.setAttribute('cy', y); c.setAttribute('r', 2.6);
c.setAttribute('class', 'meas-tick');
c.setAttribute('opacity', String(0.15 + 0.7 * (idx / measHistory.length)));
g.appendChild(c);
});
}
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + (cls || '');
}
// One predict-update cycle of the Extended Kalman Filter.
function ekfStep() {
trueState = stepState(trueState, DT);
const z = trueState[0] + Math.sqrt(R) * randn();
const F = jacobianF(est, DT);
est = stepState(est, DT);
P = add2(matMul2(matMul2(F, P), transpose2(F)), Q);
const S = P[0][0] + R; // innovation covariance S = H P H^T + R, with H = [1, 0] since only theta is observed
const K = [P[0][0] / S, P[1][0] / S]; // Kalman gain K = P H^T / S, computed from the linearized model
const y = z - est[0];
est = [est[0] + K[0] * y, est[1] + K[1] * y];
const ImKH = [[1 - K[0], 0], [-K[1], 1]];
P = matMul2(ImKH, P);
stepCount++;
if (stepCount > 20) {
sumSqErr += (est[0] - trueState[0]) ** 2;
sumSqMeas += (z - trueState[0]) ** 2;
errSamples++;
}
render(z);
if (errSamples > 5) {
const rmseEst = Math.sqrt(sumSqErr / errSamples);
const rmseMeas = Math.sqrt(sumSqMeas / errSamples);
setStatus(i18nFmt('EKF RMSE: %rmseEst% rad · raw-reading RMSE: %rmseMeas% rad', { rmseEst: rmseEst.toFixed(3), rmseMeas: rmseMeas.toFixed(3) }), 'ok');
}
}
function run() {
if (running) return;
running = true;
document.getElementById('run').textContent = 'Pause';
timer = setInterval(ekfStep, DT * 1000);
}
function pause() {
running = false;
document.getElementById('run').textContent = 'Run';
if (timer) clearInterval(timer);
}
function reset() {
pause();
trueState = [1.0, 0];
est = [0.2, 0];
P = [[1, 0], [0, 1]];
stepCount = 0;
sumSqErr = 0; sumSqMeas = 0; errSamples = 0;
measHistory.length = 0;
document.getElementById('meas-marks').innerHTML = '';
render();
setStatus('Press Run to start the pendulum and the filter.', '');
}
document.getElementById('run').addEventListener('click', () => { running ? pause() : run(); });
document.getElementById('disturb').addEventListener('click', () => {
trueState[1] += 3.2; // manually inject angular velocity to simulate an external push
setStatus('Disturbance applied — watch the EKF catch up.', '');
});
document.getElementById('reset').addEventListener('click', reset);
render();
Comments
Loading comments...