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%
6.3 KB · 184 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    packages/models/test/builders-validate.test.ts6 * Purpose: Builders + guardrails — annual totals, monotonicity, rate bounds, anti-teleportation diff gate7 */89import { describe, expect, it } from "vitest";10import { counterValue, rateAt } from "@earth-now/counter";11import {12  assertDeployable,13  buildKeelingModel,14  buildLinearYtdModel,15  buildSeasonalYtdModel,16  buildStaticRtModel,17  buildStockSplineModel,18  diffModels,19  secondsInUtcYear,20  validateModel,21} from "../src/index";2223const meta = {24  metricId: "test_metric",25  sourceId: "test_source",26  observedAt: "2026-01-01T00:00:00.000Z",27  displayHints: { decimals: 0, unit: "units" },28};2930describe("buildLinearYtdModel (L0)", () => {31  const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1_000_000 });3233  it("starts at 0 on Jan 1 UTC and ends the year at the annual total", () => {34    expect(counterValue(m, Date.parse("2026-01-01T00:00:00Z"))).toBe(0);35    expect(counterValue(m, Date.parse("2027-01-01T00:00:00Z"))).toBeCloseTo(1_000_000, 3);36  });3738  it("is leap-aware", () => {39    expect(secondsInUtcYear(2024)).toBe(366 * 86_400);40    expect(secondsInUtcYear(2026)).toBe(365 * 86_400);41  });42});4344describe("buildSeasonalYtdModel (L1)", () => {45  const m = buildSeasonalYtdModel(meta, {46    year: 2026,47    annualTotal: 1_000_000,48    shape: [{ period: "year", order: 1, relativeAmplitude: 0.3, phase: 0.5 }],49  });5051  it("keeps the annual total within 0.5 % despite seasonality", () => {52    const end = counterValue(m, Date.parse("2027-01-01T00:00:00Z"));53    expect(Math.abs(end - 1_000_000) / 1_000_000).toBeLessThan(0.005);54  });5556  it("rate stays strictly positive (cumulative honesty)", () => {57    for (let d = 0; d < 365; d += 7) {58      const t = Date.parse("2026-01-01T00:00:00Z") + d * 86_400_000;59      expect(rateAt(m, t)).toBeGreaterThan(0);60    }61  });6263  it("rejects shapes that would allow a negative rate", () => {64    expect(() =>65      buildSeasonalYtdModel(meta, {66        year: 2026,67        annualTotal: 1000,68        shape: [{ period: "year", order: 1, relativeAmplitude: 1.2, phase: 0 }],69      }),70    ).toThrow(/negative rate/);71  });72});7374describe("buildStockSplineModel (L2)", () => {75  const m = buildStockSplineModel(meta, {76    observations: [77      { time: "2023-07-01T00:00:00.000Z", value: 8_045_000_000 },78      { time: "2024-07-01T00:00:00.000Z", value: 8_119_000_000 },79      { time: "2025-07-01T00:00:00.000Z", value: 8_192_000_000 },80    ],81    forecasts: [82      { time: "2026-07-01T00:00:00.000Z", value: 8_262_000_000 },83      { time: "2027-07-01T00:00:00.000Z", value: 8_330_000_000 },84    ],85  });8687  it("anchors on the last real observation", () => {88    expect(m.anchorTime).toBe("2025-07-01T00:00:00.000Z");89    expect(counterValue(m, Date.parse(m.anchorTime))).toBeCloseTo(8_192_000_000, 3);90  });9192  it("interpolates monotonically between obs and forecast", () => {93    const issues = validateModel(94      m,95      { kind: "stock", maxAbsRatePerSec: 10 },96      { fromMs: Date.parse("2023-07-01T00:00:00Z"), toMs: Date.parse("2027-07-01T00:00:00Z") },97    );98    expect(issues).toEqual([]);99    const mid = counterValue(m, Date.parse("2026-01-01T00:00:00Z"));100    expect(mid).toBeGreaterThan(8_192_000_000);101    expect(mid).toBeLessThan(8_262_000_000);102  });103});104105describe("buildKeelingModel (L2 vedette)", () => {106  // Synthetic monthly CO₂ with known trend and cycle.107  const obs: Array<{ time: string; value: number }> = [];108  for (let y = 2021; y <= 2025; y++) {109    for (let mth = 0; mth < 12; mth++) {110      const t = Date.UTC(y, mth, 15);111      const tau = (t - Date.UTC(2000, 0, 1)) / 1000;112      const yearS = 365.2425 * 86_400;113      obs.push({114        time: new Date(t).toISOString(),115        value: 400 + (2.5 / yearS) * tau + 3 * Math.cos(((2 * Math.PI) / yearS) * tau - 0.7),116      });117    }118  }119  const m = buildKeelingModel(meta, { observations: obs });120121  it("anchor equals the fitted value at the last observation", () => {122    const anchorMs = Date.parse(m.anchorTime);123    expect(counterValue(m, anchorMs)).toBeCloseTo(m.anchorValue, 6);124  });125126  it("projects the seasonal cycle forward (≈ ±3 around the trend)", () => {127    // The synthetic cycle 3·cos(ωτ − 0.7) peaks at year-fraction 0.111 (≈ Feb 10)128    // and bottoms at 0.611 (≈ Aug 11): peak-to-trough ≈ 6 minus half a year of trend.129    const peak = counterValue(m, Date.parse("2026-02-10T00:00:00Z"));130    const trough = counterValue(m, Date.parse("2026-08-11T00:00:00Z"));131    expect(peak - trough).toBeGreaterThan(3);132  });133});134135describe("guardrails", () => {136  it("validateModel flags a decreasing cumulative", () => {137    const bad = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" });138    const withNegativeRate = {139      ...bad,140      rateFn: { kind: "linear" as const, perSecond: -1 },141    };142    const issues = validateModel(143      withNegativeRate,144      { kind: "cumulative" },145      { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-01-02T00:00:00Z") },146    );147    expect(issues.some((i) => i.code === "negative-rate")).toBe(true);148  });149150  it("validateModel flags a rate-bound violation", () => {151    const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1e12 });152    const issues = validateModel(153      m,154      { kind: "cumulative", maxAbsRatePerSec: 10 },155      { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-02-01T00:00:00Z") },156    );157    expect(issues.some((i) => i.code === "rate-bound-exceeded")).toBe(true);158  });159160  it("assertDeployable blocks teleportation between refits", () => {161    const prev = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" });162    const next = buildStaticRtModel(meta, { value: 250, at: "2026-01-02T00:00:00.000Z" });163    const swapAt = Date.parse("2026-01-02T00:00:00Z");164    expect(diffModels(prev, next, swapAt)).toBe(150);165    const issues = assertDeployable(166      prev,167      next,168      { kind: "stock", maxJumpOnRefit: 100 },169      swapAt,170      { fromMs: swapAt, toMs: swapAt + 86_400_000 },171    );172    expect(issues.some((i) => i.code === "jump-exceeded")).toBe(true);173    // Within threshold: deployable.174    const ok = assertDeployable(175      prev,176      next,177      { kind: "stock", maxJumpOnRefit: 200 },178      swapAt,179      { fromMs: swapAt, toMs: swapAt + 86_400_000 },180    );181    expect(ok).toEqual([]);182  });183});184