/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/counter/src/counter-model.ts * Purpose: The project's central contract — CounterModel and RateFunction types shared by server, client, widget and badge */ /** * A single Fourier harmonic of the instantaneous rate. * * The rate contribution at time t is: * amplitude * cos(2π * order * τ / period + phase) * where τ is the number of seconds between t and SEASONAL_EPOCH (2000-01-01T00:00:00Z), * so phases are absolute calendar phases, identical for every client. */ export interface Harmonic { /** Base period of the cycle. "year" = mean Gregorian year, "week" = 604 800 s, "day" = 86 400 s. */ period: "year" | "week" | "day"; /** Harmonic order (1 = fundamental, 2 = half-period, ...). Must be >= 1. */ order: number; /** Amplitude, in the metric's base unit per second. */ amplitude: number; /** Phase offset in radians at the seasonal epoch. */ phase: number; } export type RateFunction = /** Constant rate: v(t) = anchorValue + perSecond * (t - anchorTime). */ | { kind: "linear"; perSecond: number } /** * Piecewise-constant rate. Segments must be sorted by `from` ascending and the * first segment must start at or before anchorTime. Used e.g. for Jan-1-UTC resets. */ | { kind: "piecewise"; segments: Array<{ from: string; perSecond: number }> } /** * Seasonal rate: base + Fourier harmonics. v(t) is the analytic integral of the * rate from anchorTime, so evaluation is exact and identical on every runtime. */ | { kind: "seasonal"; base: number; harmonics: Harmonic[] } /** * Monotone-spline (PCHIP) interpolation of (time, value) knots — value knots, * not rate knots. Outside the knot range the value extrapolates linearly with * the endpoint derivative. anchorValue must equal the spline value at anchorTime. */ | { kind: "spline"; knots: Array<[time: string, value: number]> }; export interface DisplayHints { /** Fraction digits to render. */ decimals: number; /** Hard cap on significant digits — never display more precision than the model justifies. */ sigFigs?: number; /** Display unit label (SI in the data layer; conversions live here, never in the pipeline). */ unit: string; /** Display conversion factor applied before formatting (e.g. 1e-9 for t → Gt). Default 1. */ scale?: number; } export interface CounterModel { /** e.g. "co2_emissions_ytd" — key in packages/registry. */ metricId: string; /** Value at the anchor point. */ anchorValue: number; /** ISO 8601 UTC of the anchor point. */ anchorTime: string; /** How the value evolves from the anchor. */ rateFn: RateFunction; /** 90 % CI of the value at anchorTime (widens with model-specific rules client-side). */ uncertainty?: { low: number; high: number }; /** Date of the last REAL observation this model is anchored on. */ observedAt: string; /** Key in the source registry. */ sourceId: string; /** e.g. "seasonal-spline-v2" — bump on ANY output-changing modification. */ modelVersion: string; displayHints: DisplayHints; } /** Epoch used to phase seasonal harmonics: 2000-01-01T00:00:00Z, in ms. */ export const SEASONAL_EPOCH_MS = Date.UTC(2000, 0, 1); /** Mean Gregorian year, in seconds. Shared by fitting (packages/models) and evaluation. */ export const YEAR_SECONDS = 365.2425 * 86_400; /** One UTC day, in seconds. */ export const DAY_SECONDS = 86_400; /** One week, in seconds. The seasonal epoch (2000-01-01) is a Saturday — weekly phases are relative to Saturday 00:00 UTC. */ export const WEEK_SECONDS = 604_800; /** Parse a strict ISO 8601 UTC timestamp to ms since epoch; throws on invalid input. */ export function parseIsoUtc(iso: string): number { const ms = Date.parse(iso); if (Number.isNaN(ms)) throw new Error(`Invalid ISO 8601 timestamp: ${iso}`); return ms; }