/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/models/src/kalman.ts * Purpose: 1D Kalman filter (local-level) — observation/forecast fusion: track observations near them, converge to forecast further out */ export interface KalmanOptions { /** Process noise variance per step (how fast truth can drift). */ processVariance: number; /** Measurement noise variance (how much we trust each observation). */ measurementVariance: number; /** Initial state estimate. */ initialValue: number; /** Initial estimate variance. */ initialVariance: number; } export interface KalmanStep { value: number; variance: number; } /** Run a local-level Kalman filter over the series; returns the filtered state at each step. */ export function kalmanFilter(series: readonly number[], opts: KalmanOptions): KalmanStep[] { const { processVariance: q, measurementVariance: r } = opts; let x = opts.initialValue; let p = opts.initialVariance; const out: KalmanStep[] = []; for (const z of series) { // Predict. p += q; // Update. const k = p / (p + r); x += k * (z - x); p *= 1 - k; out.push({ value: x, variance: p }); } return out; } /** * Blend the last filtered observation state with an external forecast: * inverse-variance weighting where the observation's variance grows with the * horizon (q per step), so weight shifts smoothly toward the forecast. */ export function blendWithForecast( lastState: KalmanStep, stepsAhead: number, processVariance: number, forecast: { value: number; variance: number }, ): KalmanStep { const obsVariance = lastState.variance + Math.max(0, stepsAhead) * processVariance; const wObs = 1 / obsVariance; const wFc = 1 / forecast.variance; const value = (lastState.value * wObs + forecast.value * wFc) / (wObs + wFc); return { value, variance: 1 / (wObs + wFc) }; }