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%
5.5 KB · 165 lines typescript
Raw Blame History
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