SPB Git

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%
2.7 KB · 75 lines typescript
Raw Blame History
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