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/registry/src/fit.ts6 * Purpose: Fit a CounterModel for every registry metric from its fixture, run the guardrail validation, compose derived metrics7 */89import type { CounterModel, DisplayHints } from "@earth-now/counter";10import {11 type MetricConstraints,12 type ValidationIssue,13 buildKeelingModel,14 buildLinearStockModel,15 buildLinearYtdModel,16 buildSeasonalYtdModel,17 buildStaticRtModel,18 buildStockSplineModel,19 composeLinearCombination,20 validateModel,21} from "@earth-now/models";22import type { MetricEntry, MetricFixture } from "./schema.js";2324export interface FitOptions {25 /** Validation horizon (pure — the caller supplies "now"). */26 validateFromMs: number;27 validateToMs: number;28}2930export interface FitResult {31 models: Map<string, CounterModel>;32 /** Metrics whose model FAILED validation — they must NOT be served (stale badge instead). */33 blocked: Array<{ metricId: string; issues: ValidationIssue[] }>;34}3536function displayHintsOf(entry: MetricEntry, locale: "fr" | "en" = "en"): DisplayHints {37 const hints: DisplayHints = {38 decimals: entry.display.decimals,39 unit: entry.display.unit[locale],40 };41 if (entry.display.sigFigs !== undefined) hints.sigFigs = entry.display.sigFigs;42 if (entry.display.scale !== undefined) hints.scale = entry.display.scale;43 return hints;44}4546function constraintsOf(entry: MetricEntry): MetricConstraints {47 const c: MetricConstraints = {48 kind: entry.kind === "cumulative" ? "cumulative" : "stock",49 };50 if (entry.constraints.maxAbsRatePerSec !== undefined)51 c.maxAbsRatePerSec = entry.constraints.maxAbsRatePerSec;52 if (entry.constraints.maxJumpOnRefit !== undefined)53 c.maxJumpOnRefit = entry.constraints.maxJumpOnRefit;54 return c;55}5657function fitOne(entry: MetricEntry, fixture: MetricFixture): CounterModel {58 const primarySource = entry.sources[0];59 if (!primarySource) throw new Error(`Metric ${entry.id} has no source`);60 const meta = {61 metricId: entry.id,62 sourceId: primarySource.id,63 observedAt: fixture.observedAt,64 displayHints: displayHintsOf(entry),65 };66 switch (fixture.family) {67 case "seasonal-spline-v2": {68 const model = buildStockSplineModel(meta, {69 observations: fixture.observations,70 forecasts: fixture.forecasts,71 });72 return fixture.uncertainty ? { ...model, uncertainty: fixture.uncertainty } : model;73 }74 case "keeling-fusion-v1":75 return buildKeelingModel(meta, { observations: fixture.observations });76 case "seasonal-ytd-v1":77 return buildSeasonalYtdModel(meta, {78 year: fixture.year,79 annualTotal: fixture.annualTotal,80 shape: fixture.shape,81 });82 case "linear-ytd-v1":83 return buildLinearYtdModel(meta, { year: fixture.year, annualTotal: fixture.annualTotal });84 case "linear-stock-v1":85 return buildLinearStockModel(meta, {86 at: fixture.at,87 value: fixture.value,88 perSecond: fixture.perSecond,89 });90 case "static-rt-v1":91 return buildStaticRtModel(meta, { value: fixture.value, at: fixture.at });92 }93}9495/**96 * Fit models for every metric. Non-derived metrics need a fixture; derived97 * metrics are composed from their inputs (linear-combination server-side; the98 * window / rate-of / depletion-countdown ops reuse the input's model — clients99 * apply the transform with the SAME evaluated function).100 */101export function fitAllModels(102 metrics: MetricEntry[],103 fixtures: Map<string, MetricFixture>,104 opts: FitOptions,105): FitResult {106 const models = new Map<string, CounterModel>();107 const blocked: FitResult["blocked"] = [];108 const horizon = { fromMs: opts.validateFromMs, toMs: opts.validateToMs };109110 const validateAndStore = (entry: MetricEntry, model: CounterModel): void => {111 const issues = validateModel(model, constraintsOf(entry), horizon);112 if (issues.length > 0) blocked.push({ metricId: entry.id, issues });113 else models.set(entry.id, model);114 };115116 for (const entry of metrics) {117 if (entry.derived) continue;118 const fixture = fixtures.get(entry.id);119 if (!fixture) {120 blocked.push({121 metricId: entry.id,122 issues: [{ code: "nan-value", message: "no fixture / no ingested data for this metric" }],123 });124 continue;125 }126 validateAndStore(entry, fitOne(entry, fixture));127 }128129 for (const entry of metrics) {130 if (!entry.derived) continue;131 const spec = entry.derived;132 const inputModels = spec.inputs.map((i) => models.get(i.id));133 if (inputModels.some((m) => m === undefined)) {134 blocked.push({135 metricId: entry.id,136 issues: [{ code: "nan-value", message: "a derived input model is missing or blocked" }],137 });138 continue;139 }140 const primarySource = entry.sources[0]!;141 const meta = {142 metricId: entry.id,143 sourceId: primarySource.id,144 observedAt: inputModels[0]!.observedAt,145 displayHints: displayHintsOf(entry),146 };147148 if (spec.op === "linear-combination") {149 const composed = composeLinearCombination(150 meta,151 spec.inputs.map((i, idx) => ({ model: inputModels[idx]!, weight: i.weight })),152 spec.constant ?? 0,153 );154 validateAndStore(entry, composed);155 } else {156 // window / rate-of / depletion-countdown: same function, re-labelled;157 // the client applies the transform via packages/counter helpers.158 const base = inputModels[0]!;159 validateAndStore(entry, { ...base, metricId: entry.id, displayHints: meta.displayHints });160 }161 }162163 return { models, blocked };164}165