/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/models/test/filters.test.ts * Purpose: Holt-Winters and Kalman tests — nowcasting recovery and observation/forecast blending behavior */ import { describe, expect, it } from "vitest"; import { blendWithForecast, fitHoltWinters, kalmanFilter } from "../src/index"; describe("fitHoltWinters", () => { it("recovers a clean level+trend+season series and forecasts it", () => { // 4 seasons of monthly data: level 100, trend +1/step, season = 10·sin. const m = 12; const series: number[] = []; for (let i = 0; i < 4 * m; i++) { series.push(100 + i + 10 * Math.sin((2 * Math.PI * (i % m)) / m)); } const fit = fitHoltWinters(series, { alpha: 0.5, beta: 0.1, gamma: 0.3, seasonLength: m }); const truthNext = (h: number) => 100 + (series.length + h - 1) + 10 * Math.sin((2 * Math.PI * ((series.length + h - 1) % m)) / m); // Exponential smoothing carries a small lag on trending series — accept < 1.5 // on a signal of amplitude 10 with level ~150. for (const h of [1, 3, 6]) { expect(Math.abs(fit.forecast(h) - truthNext(h))).toBeLessThan(1.5); } }); it("rejects too-short series and bad params", () => { expect(() => fitHoltWinters([1, 2, 3], { alpha: 0.5, beta: 0.1, gamma: 0.1, seasonLength: 12 })).toThrow(); expect(() => fitHoltWinters(new Array(30).fill(1), { alpha: 1.5, beta: 0.1, gamma: 0.1, seasonLength: 12 }), ).toThrow(); }); }); describe("kalmanFilter + blendWithForecast", () => { it("converges to a constant signal", () => { const noisy = [10.4, 9.7, 10.1, 10.3, 9.9, 10.0, 10.2, 9.8, 10.0, 10.1]; const steps = kalmanFilter(noisy, { processVariance: 0.001, measurementVariance: 0.25, initialValue: 0, initialVariance: 100, }); const last = steps[steps.length - 1]!; expect(last.value).toBeCloseTo(10, 0); expect(last.variance).toBeLessThan(0.1); }); it("blend tracks the observation near it and the forecast far out", () => { const lastState = { value: 100, variance: 1 }; const forecast = { value: 110, variance: 4 }; const near = blendWithForecast(lastState, 1, 0.01, forecast); const far = blendWithForecast(lastState, 10_000, 0.01, forecast); // Near: mostly the observation; far: mostly the forecast. expect(near.value).toBeLessThan(103); expect(far.value).toBeGreaterThan(106); expect(near.variance).toBeLessThan(far.variance); }); });