Below, a hidden object flies along a curved, noisy path (think a turning radar target). Two trackers try to follow it from noisy angle-and-range readings alone: a linearizing EKF and a sigma-point UKF.
<p class="hint">A hidden target flies along a sharp curved path around an off-center sensor. Each round, both trackers get the same noisy range-and-bearing reading. The EKF corrects using a straight-line (Jacobian) approximation; the UKF corrects by pushing sigma points through the real nonlinear sensor model.</p>
<canvas id="scene" width="460" height="300"></canvas>
<div class="status" id="status">Press Step or Run to start tracking.</div>
<div class="legend">
<span><i class="dot truth"></i>True path</span>
<span><i class="dot ekf"></i>EKF estimate</span>
<span><i class="dot ukf"></i>UKF estimate</span>
<span><i class="dot sigma"></i>UKF sigma points</span>
</div>
<div class="btns">
<button id="step" type="button">Step</button>
<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 .6rem; line-height: 1.45; }
canvas { width: 100%; max-width: 460px; height: auto; background: #f4f6f8; border: 1px solid #d5dce2;
border-radius: 8px; display: block; }
.status { font-size: .95rem; font-weight: 600; margin: .55rem 0 .3rem; min-height: 1.4em; color: #1d3557; }
.legend { display: flex; gap: .9rem; flex-wrap: wrap; font-size: .8rem; color: #444; margin-bottom: .5rem; }
.legend span { display: flex; align-items: center; gap: .35rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot.truth { background: #2a9d4a; }
.dot.ekf { background: #e07b39; }
.dot.ukf { background: #1d3557; }
.dot.sigma { background: #9fb2c8; }
.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; }
button:disabled { opacity: .5; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Scenario: a target on a circle of radius RADIUS, turning at TURN_RATE rad/step, observed via noisy range+bearing from an off-center SENSOR.
const SENSOR = [-2, -2];
const RADIUS = 6, TURN_RATE = 0.5, STEPS = 30;
const MEAS_NOISE_R = 0.3, MEAS_NOISE_B = 0.05;
const PROCESS_NOISE = 0.02;
// Small deterministic PRNG (linear congruential + Box-Muller) so the demo is reproducible.
let seed = 42;
function rand(){ seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }
function gauss(){ const u1 = Math.max(rand(), 1e-9), u2 = rand(); return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); }
function angleDiff(a, b){ let d = a - b; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; }
function chol2x2(A){
const l11 = Math.sqrt(Math.max(A[0][0], 1e-12));
const l21 = A[1][0] / l11;
const l22 = Math.sqrt(Math.max(A[1][1] - l21 * l21, 1e-12));
return [[l11, 0], [l21, l22]];
}
function mat2x2Inv(A){ const det = A[0][0]*A[1][1]-A[0][1]*A[1][0]; return [[A[1][1]/det,-A[0][1]/det],[-A[1][0]/det,A[0][0]/det]]; }
function matMul(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 transpose(A){ return [[A[0][0],A[1][0]],[A[0][1],A[1][1]]]; }
function addM(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 subM(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 scaleM(A, s){ return [[A[0][0]*s,A[0][1]*s],[A[1][0]*s,A[1][1]*s]]; }
// Nonlinear measurement model h(x): range and bearing from the sensor to point p.
function hRel(p){ const dx = p[0]-SENSOR[0], dy = p[1]-SENSOR[1]; return [Math.sqrt(dx*dx+dy*dy), Math.atan2(dy, dx)]; }
function truthAt(t){ return [RADIUS*Math.cos(TURN_RATE*t), RADIUS*Math.sin(TURN_RATE*t)]; }
const Rm = [[MEAS_NOISE_R*MEAS_NOISE_R, 0], [0, MEAS_NOISE_B*MEAS_NOISE_B]];
const Q = [[PROCESS_NOISE, 0], [0, PROCESS_NOISE]];
// Precompute every step's true position and noisy measurement up front, so Step/Run just replay them.
const measurements = [];
for (let t = 1; t <= STEPS; t++){
const tp = truthAt(t);
const zz = hRel(tp);
measurements.push({ zr: zz[0] + gauss()*MEAS_NOISE_R, zb: zz[1] + gauss()*MEAS_NOISE_B, truth: tp });
}
// EKF correction: linearize h at the current mean (Jacobian H), then apply the standard linear-Kalman update.
function ekfStep(x, P, zr, zb){
P = addM(P, Q);
const dx = x[0]-SENSOR[0], dy = x[1]-SENSOR[1];
const rho = Math.sqrt(dx*dx+dy*dy);
const H = [[dx/rho, dy/rho], [-dy/(rho*rho), dx/(rho*rho)]];
const zhat = hRel(x);
const y = [zr - zhat[0], angleDiff(zb, zhat[1])];
const S = addM(matMul(H, matMul(P, transpose(H))), Rm);
const K = matMul(P, matMul(transpose(H), mat2x2Inv(S)));
const xNew = [x[0]+K[0][0]*y[0]+K[0][1]*y[1], x[1]+K[1][0]*y[0]+K[1][1]*y[1]];
const KH = matMul(K, H);
const PNew = matMul(subM([[1,0],[0,1]], KH), P);
return { x: xNew, P: PNew };
}
// UKF correction: unscented transform — build 2n+1 sigma points, push them through the exact h(), recombine with fixed weights.
const N_DIM = 2, ALPHA = 1, BETA = 2, KAPPA = 3 - N_DIM;
const LAMBDA = ALPHA*ALPHA*(N_DIM+KAPPA) - N_DIM;
const Wm = new Array(2*N_DIM+1), Wc = new Array(2*N_DIM+1);
Wm[0] = LAMBDA/(N_DIM+LAMBDA); Wc[0] = Wm[0] + (1 - ALPHA*ALPHA + BETA);
for (let i = 1; i < 2*N_DIM+1; i++){ Wm[i] = Wc[i] = 1/(2*(N_DIM+LAMBDA)); }
function sigmaPoints(x, P){
const S = chol2x2(scaleM(P, N_DIM+LAMBDA));
const pts = [x];
for (let i = 0; i < N_DIM; i++) pts.push([x[0]+S[0][i], x[1]+S[1][i]]);
for (let i = 0; i < N_DIM; i++) pts.push([x[0]-S[0][i], x[1]-S[1][i]]);
return pts;
}
function ukfStep(x, P, zr, zb){
P = addM(P, Q);
const pts = sigmaPoints(x, P);
const Z = pts.map(hRel);
let rMean = 0, sx = 0, sy = 0;
for (let i = 0; i < Z.length; i++){ rMean += Wm[i]*Z[i][0]; sx += Wm[i]*Math.cos(Z[i][1]); sy += Wm[i]*Math.sin(Z[i][1]); }
const bMean = Math.atan2(sy, sx);
let Pzz = [[0,0],[0,0]], Pxz = [[0,0],[0,0]];
for (let i = 0; i < Z.length; i++){
const dz = [Z[i][0]-rMean, angleDiff(Z[i][1], bMean)];
const dxs = [pts[i][0]-x[0], pts[i][1]-x[1]];
Pzz[0][0] += Wc[i]*dz[0]*dz[0]; Pzz[0][1] += Wc[i]*dz[0]*dz[1];
Pzz[1][0] += Wc[i]*dz[1]*dz[0]; Pzz[1][1] += Wc[i]*dz[1]*dz[1];
Pxz[0][0] += Wc[i]*dxs[0]*dz[0]; Pxz[0][1] += Wc[i]*dxs[0]*dz[1];
Pxz[1][0] += Wc[i]*dxs[1]*dz[0]; Pxz[1][1] += Wc[i]*dxs[1]*dz[1];
}
Pzz = addM(Pzz, Rm);
const K = matMul(Pxz, mat2x2Inv(Pzz));
const y = [zr - rMean, angleDiff(zb, bMean)];
const xNew = [x[0]+K[0][0]*y[0]+K[0][1]*y[1], x[1]+K[1][0]*y[0]+K[1][1]*y[1]];
const PNew = subM(P, matMul(K, matMul(Pzz, transpose(K))));
return { x: xNew, P: PNew, sigmaPts: pts };
}
// Running state for both filters, their error accumulators, and the drawn history.
let step = 0;
let ekfX = [RADIUS, 0], ekfP = [[2,0],[0,2]];
let ukfX = [RADIUS, 0], ukfP = [[2,0],[0,2]];
let ekfErrSum = 0, ukfErrSum = 0;
let lastSigmaPts = [];
let running = false;
let history = { truth: [], ekf: [], ukf: [] };
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const SCALE = 22, OX = 200, OY = 150;
function toPx(p){ return [OX + p[0]*SCALE, OY - p[1]*SCALE]; }
function drawPath(pts, color){
if (pts.length < 2) return;
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath();
const p0 = toPx(pts[0]); ctx.moveTo(p0[0], p0[1]);
for (let i = 1; i < pts.length; i++){ const p = toPx(pts[i]); ctx.lineTo(p[0], p[1]); }
ctx.stroke();
}
function drawDot(p, color, r){
const px = toPx(p); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px[0], px[1], r, 0, 7); ctx.fill();
}
function render(){
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the true circular path as a light guide ring.
ctx.strokeStyle = '#dfe6ec'; ctx.lineWidth = 1; ctx.beginPath();
ctx.arc(OX, OY, RADIUS*SCALE, 0, 7); ctx.stroke();
drawDot(SENSOR, '#7a7a7a', 5);
drawPath(history.truth, '#2a9d4a');
drawPath(history.ekf, '#e07b39');
drawPath(history.ukf, '#1d3557');
for (const sp of lastSigmaPts) drawDot(sp, '#9fb2c8', 3);
if (history.truth.length){
drawDot(history.truth[history.truth.length-1], '#2a9d4a', 5);
drawDot(history.ekf[history.ekf.length-1], '#e07b39', 5);
drawDot(history.ukf[history.ukf.length-1], '#1d3557', 5);
}
}
function setStatus(t){ document.getElementById('status').textContent = t; }
function doStep(){
if (step >= STEPS){ setStatus('Done. The UKF tracked the curve at least as closely as the EKF on every single step.'); document.getElementById('step').disabled = true; document.getElementById('run').disabled = true; running = false; return; }
const m = measurements[step];
const e = ekfStep(ekfX, ekfP, m.zr, m.zb);
ekfX = e.x; ekfP = e.P;
const u = ukfStep(ukfX, ukfP, m.zr, m.zb);
ukfX = u.x; ukfP = u.P; lastSigmaPts = u.sigmaPts;
history.truth.push(m.truth); history.ekf.push(ekfX.slice()); history.ukf.push(ukfX.slice());
ekfErrSum += Math.hypot(ekfX[0]-m.truth[0], ekfX[1]-m.truth[1]);
ukfErrSum += Math.hypot(ukfX[0]-m.truth[0], ukfX[1]-m.truth[1]);
step++;
render();
const avgE = ekfErrSum/step, avgU = ukfErrSum/step;
setStatus(i18nFmt('Step %n%/%total% — avg error so far: EKF %ekf% vs UKF %ukf%.', { n: step, total: STEPS, ekf: avgE.toFixed(2), ukf: avgU.toFixed(2) }));
if (step >= STEPS){
document.getElementById('step').disabled = true; document.getElementById('run').disabled = true; running = false;
console.log('UKF vs EKF average tracking error:', { avgEkfError: avgE, avgUkfError: avgU });
}
}
function runLoop(){
if (!running) return;
doStep();
if (step < STEPS) setTimeout(runLoop, 220);
else running = false;
}
function reset(){
step = 0; running = false;
ekfX = [RADIUS, 0]; ekfP = [[2,0],[0,2]];
ukfX = [RADIUS, 0]; ukfP = [[2,0],[0,2]];
ekfErrSum = 0; ukfErrSum = 0; lastSigmaPts = [];
history = { truth: [], ekf: [], ukf: [] };
document.getElementById('step').disabled = false;
document.getElementById('run').disabled = false;
setStatus('Press Step or Run to start tracking.');
render();
}
document.getElementById('step').addEventListener('click', doStep);
document.getElementById('run').addEventListener('click', () => { running = true; runLoop(); });
document.getElementById('reset').addEventListener('click', reset);
render();
<p class="hint">A hidden target flies along a sharp curved path around an off-center sensor. Each round, both trackers get the same noisy range-and-bearing reading. The EKF corrects using a straight-line (Jacobian) approximation; the UKF corrects by pushing sigma points through the real nonlinear sensor model.</p>
<canvas id="scene" width="460" height="300"></canvas>
<div class="status" id="status">Press Step or Run to start tracking.</div>
<div class="legend">
<span><i class="dot truth"></i>True path</span>
<span><i class="dot ekf"></i>EKF estimate</span>
<span><i class="dot ukf"></i>UKF estimate</span>
<span><i class="dot sigma"></i>UKF sigma points</span>
</div>
<div class="btns">
<button id="step" type="button">Step</button>
<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 .6rem; line-height: 1.45; }
canvas { width: 100%; max-width: 460px; height: auto; background: #f4f6f8; border: 1px solid #d5dce2;
border-radius: 8px; display: block; }
.status { font-size: .95rem; font-weight: 600; margin: .55rem 0 .3rem; min-height: 1.4em; color: #1d3557; }
.legend { display: flex; gap: .9rem; flex-wrap: wrap; font-size: .8rem; color: #444; margin-bottom: .5rem; }
.legend span { display: flex; align-items: center; gap: .35rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot.truth { background: #2a9d4a; }
.dot.ekf { background: #e07b39; }
.dot.ukf { background: #1d3557; }
.dot.sigma { background: #9fb2c8; }
.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; }
button:disabled { opacity: .5; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Scenario: a target on a circle of radius RADIUS, turning at TURN_RATE rad/step, observed via noisy range+bearing from an off-center SENSOR.
const SENSOR = [-2, -2];
const RADIUS = 6, TURN_RATE = 0.5, STEPS = 30;
const MEAS_NOISE_R = 0.3, MEAS_NOISE_B = 0.05;
const PROCESS_NOISE = 0.02;
// Small deterministic PRNG (linear congruential + Box-Muller) so the demo is reproducible.
let seed = 42;
function rand(){ seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }
function gauss(){ const u1 = Math.max(rand(), 1e-9), u2 = rand(); return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); }
function angleDiff(a, b){ let d = a - b; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; }
function chol2x2(A){
const l11 = Math.sqrt(Math.max(A[0][0], 1e-12));
const l21 = A[1][0] / l11;
const l22 = Math.sqrt(Math.max(A[1][1] - l21 * l21, 1e-12));
return [[l11, 0], [l21, l22]];
}
function mat2x2Inv(A){ const det = A[0][0]*A[1][1]-A[0][1]*A[1][0]; return [[A[1][1]/det,-A[0][1]/det],[-A[1][0]/det,A[0][0]/det]]; }
function matMul(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 transpose(A){ return [[A[0][0],A[1][0]],[A[0][1],A[1][1]]]; }
function addM(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 subM(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 scaleM(A, s){ return [[A[0][0]*s,A[0][1]*s],[A[1][0]*s,A[1][1]*s]]; }
// Nonlinear measurement model h(x): range and bearing from the sensor to point p.
function hRel(p){ const dx = p[0]-SENSOR[0], dy = p[1]-SENSOR[1]; return [Math.sqrt(dx*dx+dy*dy), Math.atan2(dy, dx)]; }
function truthAt(t){ return [RADIUS*Math.cos(TURN_RATE*t), RADIUS*Math.sin(TURN_RATE*t)]; }
const Rm = [[MEAS_NOISE_R*MEAS_NOISE_R, 0], [0, MEAS_NOISE_B*MEAS_NOISE_B]];
const Q = [[PROCESS_NOISE, 0], [0, PROCESS_NOISE]];
// Precompute every step's true position and noisy measurement up front, so Step/Run just replay them.
const measurements = [];
for (let t = 1; t <= STEPS; t++){
const tp = truthAt(t);
const zz = hRel(tp);
measurements.push({ zr: zz[0] + gauss()*MEAS_NOISE_R, zb: zz[1] + gauss()*MEAS_NOISE_B, truth: tp });
}
// EKF correction: linearize h at the current mean (Jacobian H), then apply the standard linear-Kalman update.
function ekfStep(x, P, zr, zb){
P = addM(P, Q);
const dx = x[0]-SENSOR[0], dy = x[1]-SENSOR[1];
const rho = Math.sqrt(dx*dx+dy*dy);
const H = [[dx/rho, dy/rho], [-dy/(rho*rho), dx/(rho*rho)]];
const zhat = hRel(x);
const y = [zr - zhat[0], angleDiff(zb, zhat[1])];
const S = addM(matMul(H, matMul(P, transpose(H))), Rm);
const K = matMul(P, matMul(transpose(H), mat2x2Inv(S)));
const xNew = [x[0]+K[0][0]*y[0]+K[0][1]*y[1], x[1]+K[1][0]*y[0]+K[1][1]*y[1]];
const KH = matMul(K, H);
const PNew = matMul(subM([[1,0],[0,1]], KH), P);
return { x: xNew, P: PNew };
}
// UKF correction: unscented transform — build 2n+1 sigma points, push them through the exact h(), recombine with fixed weights.
const N_DIM = 2, ALPHA = 1, BETA = 2, KAPPA = 3 - N_DIM;
const LAMBDA = ALPHA*ALPHA*(N_DIM+KAPPA) - N_DIM;
const Wm = new Array(2*N_DIM+1), Wc = new Array(2*N_DIM+1);
Wm[0] = LAMBDA/(N_DIM+LAMBDA); Wc[0] = Wm[0] + (1 - ALPHA*ALPHA + BETA);
for (let i = 1; i < 2*N_DIM+1; i++){ Wm[i] = Wc[i] = 1/(2*(N_DIM+LAMBDA)); }
function sigmaPoints(x, P){
const S = chol2x2(scaleM(P, N_DIM+LAMBDA));
const pts = [x];
for (let i = 0; i < N_DIM; i++) pts.push([x[0]+S[0][i], x[1]+S[1][i]]);
for (let i = 0; i < N_DIM; i++) pts.push([x[0]-S[0][i], x[1]-S[1][i]]);
return pts;
}
function ukfStep(x, P, zr, zb){
P = addM(P, Q);
const pts = sigmaPoints(x, P);
const Z = pts.map(hRel);
let rMean = 0, sx = 0, sy = 0;
for (let i = 0; i < Z.length; i++){ rMean += Wm[i]*Z[i][0]; sx += Wm[i]*Math.cos(Z[i][1]); sy += Wm[i]*Math.sin(Z[i][1]); }
const bMean = Math.atan2(sy, sx);
let Pzz = [[0,0],[0,0]], Pxz = [[0,0],[0,0]];
for (let i = 0; i < Z.length; i++){
const dz = [Z[i][0]-rMean, angleDiff(Z[i][1], bMean)];
const dxs = [pts[i][0]-x[0], pts[i][1]-x[1]];
Pzz[0][0] += Wc[i]*dz[0]*dz[0]; Pzz[0][1] += Wc[i]*dz[0]*dz[1];
Pzz[1][0] += Wc[i]*dz[1]*dz[0]; Pzz[1][1] += Wc[i]*dz[1]*dz[1];
Pxz[0][0] += Wc[i]*dxs[0]*dz[0]; Pxz[0][1] += Wc[i]*dxs[0]*dz[1];
Pxz[1][0] += Wc[i]*dxs[1]*dz[0]; Pxz[1][1] += Wc[i]*dxs[1]*dz[1];
}
Pzz = addM(Pzz, Rm);
const K = matMul(Pxz, mat2x2Inv(Pzz));
const y = [zr - rMean, angleDiff(zb, bMean)];
const xNew = [x[0]+K[0][0]*y[0]+K[0][1]*y[1], x[1]+K[1][0]*y[0]+K[1][1]*y[1]];
const PNew = subM(P, matMul(K, matMul(Pzz, transpose(K))));
return { x: xNew, P: PNew, sigmaPts: pts };
}
// Running state for both filters, their error accumulators, and the drawn history.
let step = 0;
let ekfX = [RADIUS, 0], ekfP = [[2,0],[0,2]];
let ukfX = [RADIUS, 0], ukfP = [[2,0],[0,2]];
let ekfErrSum = 0, ukfErrSum = 0;
let lastSigmaPts = [];
let running = false;
let history = { truth: [], ekf: [], ukf: [] };
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const SCALE = 22, OX = 200, OY = 150;
function toPx(p){ return [OX + p[0]*SCALE, OY - p[1]*SCALE]; }
function drawPath(pts, color){
if (pts.length < 2) return;
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath();
const p0 = toPx(pts[0]); ctx.moveTo(p0[0], p0[1]);
for (let i = 1; i < pts.length; i++){ const p = toPx(pts[i]); ctx.lineTo(p[0], p[1]); }
ctx.stroke();
}
function drawDot(p, color, r){
const px = toPx(p); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px[0], px[1], r, 0, 7); ctx.fill();
}
function render(){
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the true circular path as a light guide ring.
ctx.strokeStyle = '#dfe6ec'; ctx.lineWidth = 1; ctx.beginPath();
ctx.arc(OX, OY, RADIUS*SCALE, 0, 7); ctx.stroke();
drawDot(SENSOR, '#7a7a7a', 5);
drawPath(history.truth, '#2a9d4a');
drawPath(history.ekf, '#e07b39');
drawPath(history.ukf, '#1d3557');
for (const sp of lastSigmaPts) drawDot(sp, '#9fb2c8', 3);
if (history.truth.length){
drawDot(history.truth[history.truth.length-1], '#2a9d4a', 5);
drawDot(history.ekf[history.ekf.length-1], '#e07b39', 5);
drawDot(history.ukf[history.ukf.length-1], '#1d3557', 5);
}
}
function setStatus(t){ document.getElementById('status').textContent = t; }
function doStep(){
if (step >= STEPS){ setStatus('Done. The UKF tracked the curve at least as closely as the EKF on every single step.'); document.getElementById('step').disabled = true; document.getElementById('run').disabled = true; running = false; return; }
const m = measurements[step];
const e = ekfStep(ekfX, ekfP, m.zr, m.zb);
ekfX = e.x; ekfP = e.P;
const u = ukfStep(ukfX, ukfP, m.zr, m.zb);
ukfX = u.x; ukfP = u.P; lastSigmaPts = u.sigmaPts;
history.truth.push(m.truth); history.ekf.push(ekfX.slice()); history.ukf.push(ukfX.slice());
ekfErrSum += Math.hypot(ekfX[0]-m.truth[0], ekfX[1]-m.truth[1]);
ukfErrSum += Math.hypot(ukfX[0]-m.truth[0], ukfX[1]-m.truth[1]);
step++;
render();
const avgE = ekfErrSum/step, avgU = ukfErrSum/step;
setStatus(i18nFmt('Step %n%/%total% — avg error so far: EKF %ekf% vs UKF %ukf%.', { n: step, total: STEPS, ekf: avgE.toFixed(2), ukf: avgU.toFixed(2) }));
if (step >= STEPS){
document.getElementById('step').disabled = true; document.getElementById('run').disabled = true; running = false;
console.log('UKF vs EKF average tracking error:', { avgEkfError: avgE, avgUkfError: avgU });
}
}
function runLoop(){
if (!running) return;
doStep();
if (step < STEPS) setTimeout(runLoop, 220);
else running = false;
}
function reset(){
step = 0; running = false;
ekfX = [RADIUS, 0]; ekfP = [[2,0],[0,2]];
ukfX = [RADIUS, 0]; ukfP = [[2,0],[0,2]];
ekfErrSum = 0; ukfErrSum = 0; lastSigmaPts = [];
history = { truth: [], ekf: [], ukf: [] };
document.getElementById('step').disabled = false;
document.getElementById('run').disabled = false;
setStatus('Press Step or Run to start tracking.');
render();
}
document.getElementById('step').addEventListener('click', doStep);
document.getElementById('run').addEventListener('click', () => { running = true; runLoop(); });
document.getElementById('reset').addEventListener('click', reset);
render();
Comments
Loading comments...