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%
8.3 KB · 256 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    packages/models/src/builders.ts6 * Purpose: Model builders (fitters) producing versioned CounterModels — one per model family declared in the registry7 */89import {10  type CounterModel,11  type DisplayHints,12  type Harmonic,13  YEAR_SECONDS,14  counterValue,15  parseIsoUtc,16} from "@earth-now/counter";17import { fitFourier } from "./fourier.js";1819/**20 * Model family versions. Bump on ANY output-changing modification and update the21 * golden fixtures — CI fails otherwise.22 */23export const MODEL_VERSIONS = {24  linearYtd: "linear-ytd-v1",25  linearStock: "linear-stock-v1",26  seasonalYtd: "seasonal-ytd-v1",27  stockSpline: "seasonal-spline-v2",28  keeling: "keeling-fusion-v1",29  staticRt: "static-rt-v1",30  composed: "composed-v1",31} as const;3233interface CommonMeta {34  metricId: string;35  sourceId: string;36  observedAt: string;37  displayHints: DisplayHints;38  uncertainty?: { low: number; high: number };39}4041/** Seconds in a specific UTC calendar year (leap-aware). */42export function secondsInUtcYear(year: number): number {43  return (Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 1000;44}4546/**47 * L0 — Level 0 linear YTD cumulative: annual total spread uniformly over the48 * year, anchored at Jan 1 UTC with value 0. Fallback / genuinely quasi-linear49 * metrics only.50 */51export function buildLinearYtdModel(52  meta: CommonMeta,53  params: { year: number; annualTotal: number },54): CounterModel {55  const { year, annualTotal } = params;56  if (annualTotal < 0) throw new Error("buildLinearYtdModel: cumulative total must be >= 0");57  return {58    ...meta,59    anchorValue: 0,60    anchorTime: new Date(Date.UTC(year, 0, 1)).toISOString(),61    rateFn: { kind: "linear", perSecond: annualTotal / secondsInUtcYear(year) },62    modelVersion: MODEL_VERSIONS.linearYtd,63  };64}6566export interface SeasonalShape {67  period: "year" | "week" | "day";68  order: number;69  /** Relative amplitude as a fraction of the base rate (|Σ| must stay < 1). */70  relativeAmplitude: number;71  /** Phase in radians at the seasonal epoch. */72  phase: number;73}7475/**76 * L1 — seasonal YTD cumulative: annual total + declared seasonal shape77 * (Fourier harmonics as fractions of the base rate). The relative-amplitude sum78 * is checked < 1 so the instantaneous rate stays strictly positive — a cumulative79 * counter can never tick backwards.80 */81export function buildSeasonalYtdModel(82  meta: CommonMeta,83  params: { year: number; annualTotal: number; shape: SeasonalShape[] },84): CounterModel {85  const { year, annualTotal, shape } = params;86  if (annualTotal < 0) throw new Error("buildSeasonalYtdModel: cumulative total must be >= 0");87  const totalRel = shape.reduce((s, h) => s + Math.abs(h.relativeAmplitude), 0);88  if (totalRel >= 1) {89    throw new Error(90      `buildSeasonalYtdModel: Σ|relativeAmplitude| = ${totalRel} would allow a negative rate`,91    );92  }93  const base = annualTotal / secondsInUtcYear(year);94  const harmonics: Harmonic[] = shape.map((h) => ({95    period: h.period,96    order: h.order,97    amplitude: h.relativeAmplitude * base,98    phase: h.phase,99  }));100  const anchorTime = new Date(Date.UTC(year, 0, 1)).toISOString();101  return {102    ...meta,103    anchorValue: 0,104    anchorTime,105    rateFn: { kind: "seasonal", base, harmonics },106    modelVersion: MODEL_VERSIONS.seasonalYtd,107  };108}109110export interface TimeValuePoint {111  time: string;112  value: number;113}114115/**116 * L2 — observation/forecast fusion for stocks: monotone PCHIP spline through117 * past observations and source forecasts (e.g. UN WPP median). Anchored at the118 * last real observation. Family name kept from the registry contract119 * ("seasonal-spline-v2"); the seasonal component is zero for aseasonal stocks.120 */121export function buildStockSplineModel(122  meta: CommonMeta,123  params: { observations: TimeValuePoint[]; forecasts: TimeValuePoint[] },124): CounterModel {125  const points = [...params.observations, ...params.forecasts].sort(126    (a, b) => parseIsoUtc(a.time) - parseIsoUtc(b.time),127  );128  if (points.length < 2) throw new Error("buildStockSplineModel: need >= 2 points");129  const lastObs = params.observations[params.observations.length - 1];130  if (!lastObs) throw new Error("buildStockSplineModel: need at least one observation");131  return {132    ...meta,133    anchorValue: lastObs.value,134    anchorTime: lastObs.time,135    rateFn: { kind: "spline", knots: points.map((p) => [p.time, p.value]) },136    modelVersion: MODEL_VERSIONS.stockSpline,137  };138}139140/**141 * L2 vedette — Keeling-style stock: least-squares fit of linear trend + annual142 * harmonics (orders 1 & 2) on the observation series. The anchor is the FITTED143 * value at the last observation (smooth junction, no step at deploy time).144 */145export function buildKeelingModel(146  meta: CommonMeta,147  params: {148    observations: TimeValuePoint[];149    harmonics?: Array<{ period: "year" | "day"; order: number }>;150  },151): CounterModel {152  const harmonics = params.harmonics ?? [153    { period: "year", order: 1 },154    { period: "year", order: 2 },155  ];156  const fit = fitFourier({ observations: params.observations, harmonics });157  const last = params.observations[params.observations.length - 1];158  if (!last) throw new Error("buildKeelingModel: empty observations");159  const anchorMs = parseIsoUtc(last.time);160  return {161    ...meta,162    anchorValue: fit.valueAt(anchorMs),163    anchorTime: last.time,164    rateFn: { kind: "seasonal", base: fit.trendPerSecond, harmonics: fit.rateHarmonics },165    modelVersion: MODEL_VERSIONS.keeling,166  };167}168169/**170 * RT — static value for event-driven metrics between true real-time updates171 * (earthquake counts, humans in space). Re-anchored by the API on each event;172 * never interpolated.173 */174export function buildStaticRtModel(meta: CommonMeta, params: { value: number; at: string }): CounterModel {175  return {176    ...meta,177    anchorValue: params.value,178    anchorTime: params.at,179    rateFn: { kind: "linear", perSecond: 0 },180    modelVersion: MODEL_VERSIONS.staticRt,181  };182}183184/** Convenience: expected mean rate of an annual total (per second, mean Gregorian year). */185export function annualTotalToMeanRate(annualTotal: number): number {186  return annualTotal / YEAR_SECONDS;187}188189/**190 * L0 — linear stock (e.g. a countdown: days before the next Earth Overshoot Day,191 * perSecond = −1/86400). Anchored on the given observation.192 */193export function buildLinearStockModel(194  meta: CommonMeta,195  params: { at: string; value: number; perSecond: number },196): CounterModel {197  return {198    ...meta,199    anchorValue: params.value,200    anchorTime: params.at,201    rateFn: { kind: "linear", perSecond: params.perSecond },202    modelVersion: MODEL_VERSIONS.linearStock,203  };204}205206/**207 * Derived metric composition: constant + Σ weightᵢ · modelᵢ(t), exact for208 * linear and seasonal inputs (rates add analytically). Used server-side so the209 * badge, widget and dashboard all animate the same composed function210 * (net growth = births − deaths; carbon budget = budget − emissions; scalings).211 */212export function composeLinearCombination(213  meta: CommonMeta,214  inputs: Array<{ model: CounterModel; weight: number }>,215  constant = 0,216): CounterModel {217  if (inputs.length === 0) throw new Error("composeLinearCombination: need >= 1 input");218  for (const { model } of inputs) {219    const kind = model.rateFn.kind;220    if (kind !== "linear" && kind !== "seasonal") {221      throw new Error(222        `composeLinearCombination: unsupported input rateFn '${kind}' (linear/seasonal only)`,223      );224    }225  }226  // Reference instant: the latest input anchor.227  const refMs = Math.max(...inputs.map(({ model }) => parseIsoUtc(model.anchorTime)));228  const refIso = new Date(refMs).toISOString();229230  let base = 0;231  const harmonics: Harmonic[] = [];232  let anchorValue = constant;233  for (const { model, weight } of inputs) {234    anchorValue += weight * counterValue(model, refMs);235    if (model.rateFn.kind === "linear") {236      base += weight * model.rateFn.perSecond;237    } else if (model.rateFn.kind === "seasonal") {238      base += weight * model.rateFn.base;239      for (const h of model.rateFn.harmonics) {240        harmonics.push({ ...h, amplitude: h.amplitude * weight });241      }242    }243  }244245  return {246    ...meta,247    anchorValue,248    anchorTime: refIso,249    rateFn:250      harmonics.length === 0251        ? { kind: "linear", perSecond: base }252        : { kind: "seasonal", base, harmonics },253    modelVersion: MODEL_VERSIONS.composed,254  };255}256