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%
3.8 KB · 112 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    packages/models/src/fourier.ts6 * Purpose: Least-squares fit of trend + Fourier harmonics in the VALUE domain, converted to rate harmonics for CounterModel7 */89import {10  type Harmonic,11  DAY_SECONDS,12  SEASONAL_EPOCH_MS,13  WEEK_SECONDS,14  YEAR_SECONDS,15  parseIsoUtc,16} from "@earth-now/counter";17import { leastSquares } from "./linalg.js";1819export interface FourierFitInput {20  /** Observations: ISO time → value. Needs >= 2·harmonics + 2 points. */21  observations: Array<{ time: string; value: number }>;22  /** Harmonic spec to fit, e.g. [{period:"year", order:1}, {period:"year", order:2}]. */23  harmonics: Array<{ period: "year" | "week" | "day"; order: number }>;24}2526export interface FourierFitResult {27  /** Value at the seasonal epoch (intercept). */28  intercept: number;29  /** Linear trend of the value, per second. Becomes rateFn.base. */30  trendPerSecond: number;31  /** Rate-domain harmonics ready for a `seasonal` RateFunction. */32  rateHarmonics: Harmonic[];33  /** Evaluate the fitted VALUE curve at t (ms). */34  valueAt: (tMs: number) => number;35  /** Root-mean-square residual of the fit. */36  rmse: number;37}3839function omegaOf(period: "year" | "week" | "day", order: number): number {40  const seconds =41    period === "year" ? YEAR_SECONDS : period === "week" ? WEEK_SECONDS : DAY_SECONDS;42  return (2 * Math.PI * order) / seconds;43}4445/**46 * Fit value(τ) = a + b·τ + Σ_h [ c_h·cos(ω_h τ) + s_h·sin(ω_h τ) ], τ in seconds47 * since the seasonal epoch. The derivative of each fitted value harmonic is an48 * exact rate harmonic, so the returned Harmonics reproduce the fitted curve when49 * integrated by packages/counter — the client animates the very curve we fitted.50 */51export function fitFourier(input: FourierFitInput): FourierFitResult {52  const { observations, harmonics } = input;53  const k = 2 + 2 * harmonics.length;54  if (observations.length < k) {55    throw new Error(56      `fitFourier: need at least ${k} observations for ${harmonics.length} harmonics, got ${observations.length}`,57    );58  }5960  const taus = observations.map((o) => (parseIsoUtc(o.time) - SEASONAL_EPOCH_MS) / 1000);61  // Center τ for conditioning; un-center the intercept afterwards.62  const tauMean = taus.reduce((s, t) => s + t, 0) / taus.length;63  const omegas = harmonics.map((h) => omegaOf(h.period, h.order));6465  const X = taus.map((tau) => {66    const row = [1, tau - tauMean];67    for (const omega of omegas) row.push(Math.cos(omega * tau), Math.sin(omega * tau));68    return row;69  });70  const y = observations.map((o) => o.value);71  const beta = leastSquares(X, y);7273  const b = beta[1]!;74  const a = beta[0]! - b * tauMean;75  const coefs = harmonics.map((h, i) => ({76    ...h,77    c: beta[2 + 2 * i]!,78    s: beta[3 + 2 * i]!,79    omega: omegas[i]!,80  }));8182  const valueAt = (tMs: number): number => {83    const tau = (tMs - SEASONAL_EPOCH_MS) / 1000;84    let v = a + b * tau;85    for (const { c, s, omega } of coefs) v += c * Math.cos(omega * tau) + s * Math.sin(omega * tau);86    return v;87  };8889  let sse = 0;90  for (let i = 0; i < taus.length; i++) {91    const r = valueAt(parseIsoUtc(observations[i]!.time)) - y[i]!;92    sse += r * r;93  }9495  // d/dτ [c·cos(ωτ) + s·sin(ωτ)] = (s·ω)·cos(ωτ) − (c·ω)·sin(ωτ)96  //                              = A·ω·cos(ωτ + φ) with A = √(c²+s²), φ = atan2(c, s)... derived below.97  const rateHarmonics: Harmonic[] = coefs.map(({ period, order, c, s, omega }) => {98    const amplitude = Math.hypot(c, s) * omega;99    // Write derivative as R·cos(ωτ + φ): R·cosφ = s·ω, R·sinφ = c·ω ⇒ φ = atan2(c, s).100    const phase = Math.atan2(c, s);101    return { period, order, amplitude, phase };102  });103104  return {105    intercept: a,106    trendPerSecond: b,107    rateHarmonics,108    valueAt,109    rmse: Math.sqrt(sse / taus.length),110  };111}112