/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/models/src/holt-winters.ts * Purpose: Additive Holt-Winters (level/trend/seasonal) for nowcasting frequently-published metrics without a source forecast */ export interface HoltWintersParams { /** Level smoothing 0..1. */ alpha: number; /** Trend smoothing 0..1. */ beta: number; /** Seasonal smoothing 0..1. */ gamma: number; /** Season length in observations (e.g. 12 for monthly data with yearly seasonality). */ seasonLength: number; } export interface HoltWintersFit { level: number; trend: number; seasonals: number[]; /** Forecast h steps ahead of the last observation. */ forecast: (h: number) => number; /** One-step-ahead fitted values (same length as input, first season is initialization). */ fitted: number[]; } /** Additive Holt-Winters. Needs at least two full seasons of data. */ export function fitHoltWinters(series: readonly number[], params: HoltWintersParams): HoltWintersFit { const { alpha, beta, gamma, seasonLength: m } = params; if (m < 2) throw new Error("holt-winters: seasonLength must be >= 2"); if (series.length < 2 * m) throw new Error(`holt-winters: need >= ${2 * m} observations, got ${series.length}`); for (const p of [alpha, beta, gamma]) { if (p < 0 || p > 1) throw new Error("holt-winters: smoothing params must be in [0,1]"); } // Initialization: first-season mean level, trend from season-over-season means, // seasonal indices as DETRENDED deviations from the first-season mean — without // detrending, a linear trend ramp pollutes the seasonal profile. const season1 = series.slice(0, m); const season2 = series.slice(m, 2 * m); const mean1 = season1.reduce((s, v) => s + v, 0) / m; const mean2 = season2.reduce((s, v) => s + v, 0) / m; let level = mean1; let trend = (mean2 - mean1) / m; const seasonals = season1.map((v, i) => v - (mean1 + (i - (m - 1) / 2) * trend)); const fitted: number[] = []; for (let i = 0; i < series.length; i++) { const si = i % m; const predicted = level + trend + seasonals[si]!; fitted.push(predicted); const v = series[i]!; const prevLevel = level; level = alpha * (v - seasonals[si]!) + (1 - alpha) * (level + trend); trend = beta * (level - prevLevel) + (1 - beta) * trend; seasonals[si] = gamma * (v - level) + (1 - gamma) * seasonals[si]!; } return { level, trend, seasonals, fitted, forecast: (h: number) => { if (h < 1) throw new Error("holt-winters: forecast horizon must be >= 1"); const si = (series.length + h - 1) % m; return level + h * trend + seasonals[si]!; }, }; }