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/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