/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/models/src/builders.ts * Purpose: Model builders (fitters) producing versioned CounterModels — one per model family declared in the registry */ import { type CounterModel, type DisplayHints, type Harmonic, YEAR_SECONDS, counterValue, parseIsoUtc, } from "@earth-now/counter"; import { fitFourier } from "./fourier.js"; /** * Model family versions. Bump on ANY output-changing modification and update the * golden fixtures — CI fails otherwise. */ export const MODEL_VERSIONS = { linearYtd: "linear-ytd-v1", linearStock: "linear-stock-v1", seasonalYtd: "seasonal-ytd-v1", stockSpline: "seasonal-spline-v2", keeling: "keeling-fusion-v1", staticRt: "static-rt-v1", composed: "composed-v1", } as const; interface CommonMeta { metricId: string; sourceId: string; observedAt: string; displayHints: DisplayHints; uncertainty?: { low: number; high: number }; } /** Seconds in a specific UTC calendar year (leap-aware). */ export function secondsInUtcYear(year: number): number { return (Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 1000; } /** * L0 — Level 0 linear YTD cumulative: annual total spread uniformly over the * year, anchored at Jan 1 UTC with value 0. Fallback / genuinely quasi-linear * metrics only. */ export function buildLinearYtdModel( meta: CommonMeta, params: { year: number; annualTotal: number }, ): CounterModel { const { year, annualTotal } = params; if (annualTotal < 0) throw new Error("buildLinearYtdModel: cumulative total must be >= 0"); return { ...meta, anchorValue: 0, anchorTime: new Date(Date.UTC(year, 0, 1)).toISOString(), rateFn: { kind: "linear", perSecond: annualTotal / secondsInUtcYear(year) }, modelVersion: MODEL_VERSIONS.linearYtd, }; } export interface SeasonalShape { period: "year" | "week" | "day"; order: number; /** Relative amplitude as a fraction of the base rate (|Σ| must stay < 1). */ relativeAmplitude: number; /** Phase in radians at the seasonal epoch. */ phase: number; } /** * L1 — seasonal YTD cumulative: annual total + declared seasonal shape * (Fourier harmonics as fractions of the base rate). The relative-amplitude sum * is checked < 1 so the instantaneous rate stays strictly positive — a cumulative * counter can never tick backwards. */ export function buildSeasonalYtdModel( meta: CommonMeta, params: { year: number; annualTotal: number; shape: SeasonalShape[] }, ): CounterModel { const { year, annualTotal, shape } = params; if (annualTotal < 0) throw new Error("buildSeasonalYtdModel: cumulative total must be >= 0"); const totalRel = shape.reduce((s, h) => s + Math.abs(h.relativeAmplitude), 0); if (totalRel >= 1) { throw new Error( `buildSeasonalYtdModel: Σ|relativeAmplitude| = ${totalRel} would allow a negative rate`, ); } const base = annualTotal / secondsInUtcYear(year); const harmonics: Harmonic[] = shape.map((h) => ({ period: h.period, order: h.order, amplitude: h.relativeAmplitude * base, phase: h.phase, })); const anchorTime = new Date(Date.UTC(year, 0, 1)).toISOString(); return { ...meta, anchorValue: 0, anchorTime, rateFn: { kind: "seasonal", base, harmonics }, modelVersion: MODEL_VERSIONS.seasonalYtd, }; } export interface TimeValuePoint { time: string; value: number; } /** * L2 — observation/forecast fusion for stocks: monotone PCHIP spline through * past observations and source forecasts (e.g. UN WPP median). Anchored at the * last real observation. Family name kept from the registry contract * ("seasonal-spline-v2"); the seasonal component is zero for aseasonal stocks. */ export function buildStockSplineModel( meta: CommonMeta, params: { observations: TimeValuePoint[]; forecasts: TimeValuePoint[] }, ): CounterModel { const points = [...params.observations, ...params.forecasts].sort( (a, b) => parseIsoUtc(a.time) - parseIsoUtc(b.time), ); if (points.length < 2) throw new Error("buildStockSplineModel: need >= 2 points"); const lastObs = params.observations[params.observations.length - 1]; if (!lastObs) throw new Error("buildStockSplineModel: need at least one observation"); return { ...meta, anchorValue: lastObs.value, anchorTime: lastObs.time, rateFn: { kind: "spline", knots: points.map((p) => [p.time, p.value]) }, modelVersion: MODEL_VERSIONS.stockSpline, }; } /** * L2 vedette — Keeling-style stock: least-squares fit of linear trend + annual * harmonics (orders 1 & 2) on the observation series. The anchor is the FITTED * value at the last observation (smooth junction, no step at deploy time). */ export function buildKeelingModel( meta: CommonMeta, params: { observations: TimeValuePoint[]; harmonics?: Array<{ period: "year" | "day"; order: number }>; }, ): CounterModel { const harmonics = params.harmonics ?? [ { period: "year", order: 1 }, { period: "year", order: 2 }, ]; const fit = fitFourier({ observations: params.observations, harmonics }); const last = params.observations[params.observations.length - 1]; if (!last) throw new Error("buildKeelingModel: empty observations"); const anchorMs = parseIsoUtc(last.time); return { ...meta, anchorValue: fit.valueAt(anchorMs), anchorTime: last.time, rateFn: { kind: "seasonal", base: fit.trendPerSecond, harmonics: fit.rateHarmonics }, modelVersion: MODEL_VERSIONS.keeling, }; } /** * RT — static value for event-driven metrics between true real-time updates * (earthquake counts, humans in space). Re-anchored by the API on each event; * never interpolated. */ export function buildStaticRtModel(meta: CommonMeta, params: { value: number; at: string }): CounterModel { return { ...meta, anchorValue: params.value, anchorTime: params.at, rateFn: { kind: "linear", perSecond: 0 }, modelVersion: MODEL_VERSIONS.staticRt, }; } /** Convenience: expected mean rate of an annual total (per second, mean Gregorian year). */ export function annualTotalToMeanRate(annualTotal: number): number { return annualTotal / YEAR_SECONDS; } /** * L0 — linear stock (e.g. a countdown: days before the next Earth Overshoot Day, * perSecond = −1/86400). Anchored on the given observation. */ export function buildLinearStockModel( meta: CommonMeta, params: { at: string; value: number; perSecond: number }, ): CounterModel { return { ...meta, anchorValue: params.value, anchorTime: params.at, rateFn: { kind: "linear", perSecond: params.perSecond }, modelVersion: MODEL_VERSIONS.linearStock, }; } /** * Derived metric composition: constant + Σ weightᵢ · modelᵢ(t), exact for * linear and seasonal inputs (rates add analytically). Used server-side so the * badge, widget and dashboard all animate the same composed function * (net growth = births − deaths; carbon budget = budget − emissions; scalings). */ export function composeLinearCombination( meta: CommonMeta, inputs: Array<{ model: CounterModel; weight: number }>, constant = 0, ): CounterModel { if (inputs.length === 0) throw new Error("composeLinearCombination: need >= 1 input"); for (const { model } of inputs) { const kind = model.rateFn.kind; if (kind !== "linear" && kind !== "seasonal") { throw new Error( `composeLinearCombination: unsupported input rateFn '${kind}' (linear/seasonal only)`, ); } } // Reference instant: the latest input anchor. const refMs = Math.max(...inputs.map(({ model }) => parseIsoUtc(model.anchorTime))); const refIso = new Date(refMs).toISOString(); let base = 0; const harmonics: Harmonic[] = []; let anchorValue = constant; for (const { model, weight } of inputs) { anchorValue += weight * counterValue(model, refMs); if (model.rateFn.kind === "linear") { base += weight * model.rateFn.perSecond; } else if (model.rateFn.kind === "seasonal") { base += weight * model.rateFn.base; for (const h of model.rateFn.harmonics) { harmonics.push({ ...h, amplitude: h.amplitude * weight }); } } } return { ...meta, anchorValue, anchorTime: refIso, rateFn: harmonics.length === 0 ? { kind: "linear", perSecond: base } : { kind: "seasonal", base, harmonics }, modelVersion: MODEL_VERSIONS.composed, }; }