spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: packages/models/src/holt-winters.ts6 * Purpose: Additive Holt-Winters (level/trend/seasonal) for nowcasting frequently-published metrics without a source forecast7 */89export interface HoltWintersParams {10 /** Level smoothing 0..1. */11 alpha: number;12 /** Trend smoothing 0..1. */13 beta: number;14 /** Seasonal smoothing 0..1. */15 gamma: number;16 /** Season length in observations (e.g. 12 for monthly data with yearly seasonality). */17 seasonLength: number;18}1920export interface HoltWintersFit {21 level: number;22 trend: number;23 seasonals: number[];24 /** Forecast h steps ahead of the last observation. */25 forecast: (h: number) => number;26 /** One-step-ahead fitted values (same length as input, first season is initialization). */27 fitted: number[];28}2930/** Additive Holt-Winters. Needs at least two full seasons of data. */31export function fitHoltWinters(series: readonly number[], params: HoltWintersParams): HoltWintersFit {32 const { alpha, beta, gamma, seasonLength: m } = params;33 if (m < 2) throw new Error("holt-winters: seasonLength must be >= 2");34 if (series.length < 2 * m)35 throw new Error(`holt-winters: need >= ${2 * m} observations, got ${series.length}`);36 for (const p of [alpha, beta, gamma]) {37 if (p < 0 || p > 1) throw new Error("holt-winters: smoothing params must be in [0,1]");38 }3940 // Initialization: first-season mean level, trend from season-over-season means,41 // seasonal indices as DETRENDED deviations from the first-season mean — without42 // detrending, a linear trend ramp pollutes the seasonal profile.43 const season1 = series.slice(0, m);44 const season2 = series.slice(m, 2 * m);45 const mean1 = season1.reduce((s, v) => s + v, 0) / m;46 const mean2 = season2.reduce((s, v) => s + v, 0) / m;47 let level = mean1;48 let trend = (mean2 - mean1) / m;49 const seasonals = season1.map((v, i) => v - (mean1 + (i - (m - 1) / 2) * trend));5051 const fitted: number[] = [];52 for (let i = 0; i < series.length; i++) {53 const si = i % m;54 const predicted = level + trend + seasonals[si]!;55 fitted.push(predicted);56 const v = series[i]!;57 const prevLevel = level;58 level = alpha * (v - seasonals[si]!) + (1 - alpha) * (level + trend);59 trend = beta * (level - prevLevel) + (1 - beta) * trend;60 seasonals[si] = gamma * (v - level) + (1 - gamma) * seasonals[si]!;61 }6263 return {64 level,65 trend,66 seasonals,67 fitted,68 forecast: (h: number) => {69 if (h < 1) throw new Error("holt-winters: forecast horizon must be >= 1");70 const si = (series.length + h - 1) % m;71 return level + h * trend + seasonals[si]!;72 },73 };74}75