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%
3.8 KB · 96 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    apps/web/test/smoothing.test.ts6 * Purpose: Tests for the 60 s model-swap smoothing — exact old value at swap start, exact new value at swap end, linear blend in between7 */89import { describe, expect, it } from "vitest";10import { counterValue, type CounterModel } from "@earth-now/counter";11import {12  blendValues,13  makeSmoothedEvaluator,14  SMOOTHING_DURATION_MS,15  smoothingAlpha,16} from "../lib/smoothing";1718const ANCHOR = "2026-01-01T00:00:00.000Z";1920function linearModel(anchorValue: number, perSecond: number): CounterModel {21  return {22    metricId: "test_metric",23    anchorValue,24    anchorTime: ANCHOR,25    rateFn: { kind: "linear", perSecond },26    observedAt: "2025-12-31T00:00:00.000Z",27    sourceId: "test_source",28    modelVersion: "linear-test-v1",29    displayHints: { decimals: 0, unit: "u" },30  };31}3233describe("makeSmoothedEvaluator", () => {34  const oldModel = linearModel(1_000, 1);35  const newModel = linearModel(1_600, 2);36  const swapStart = Date.UTC(2026, 0, 2, 12, 0, 0);37  const duration = SMOOTHING_DURATION_MS;38  const evaluate = makeSmoothedEvaluator(oldModel, newModel, swapStart, duration);3940  it("returns exactly the old model value at swap start (and before)", () => {41    expect(evaluate(swapStart)).toBe(counterValue(oldModel, swapStart));42    expect(evaluate(swapStart - 5_000)).toBe(counterValue(oldModel, swapStart - 5_000));43  });4445  it("returns exactly the new model value at swap start + duration (and after)", () => {46    const end = swapStart + duration;47    expect(evaluate(end)).toBe(counterValue(newModel, end));48    expect(evaluate(end + 120_000)).toBe(counterValue(newModel, end + 120_000));49  });5051  it("is the arithmetic mean of both models at the midpoint", () => {52    const mid = swapStart + duration / 2;53    const expected = (counterValue(oldModel, mid) + counterValue(newModel, mid)) / 2;54    expect(evaluate(mid)).toBeCloseTo(expected, 9);55  });5657  it("blends monotonically from old towards new across the window", () => {58    const quarter = evaluate(swapStart + duration / 4);59    const mid = evaluate(swapStart + duration / 2);60    const threeQuarters = evaluate(swapStart + (3 * duration) / 4);61    const oldMid = counterValue(oldModel, swapStart + duration / 2);62    const newMid = counterValue(newModel, swapStart + duration / 2);63    const lo = Math.min(oldMid, newMid);64    const hi = Math.max(oldMid, newMid);65    expect(mid).toBeGreaterThanOrEqual(lo);66    expect(mid).toBeLessThanOrEqual(hi);67    // With new > old everywhere here, the blend weight grows with t.68    expect(quarter).toBeLessThan(mid);69    expect(mid).toBeLessThan(threeQuarters);70  });7172  it("treats a non-positive duration as an instant swap to the new model", () => {73    const instant = makeSmoothedEvaluator(oldModel, newModel, swapStart, 0);74    expect(instant(swapStart)).toBe(counterValue(newModel, swapStart));75    expect(instant(swapStart - 1)).toBe(counterValue(newModel, swapStart - 1));76  });77});7879describe("smoothingAlpha / blendValues", () => {80  const swapStart = 1_000_000;8182  it("clamps alpha to [0, 1]", () => {83    expect(smoothingAlpha(swapStart - 1, swapStart, 60_000)).toBe(0);84    expect(smoothingAlpha(swapStart, swapStart, 60_000)).toBe(0);85    expect(smoothingAlpha(swapStart + 30_000, swapStart, 60_000)).toBe(0.5);86    expect(smoothingAlpha(swapStart + 60_000, swapStart, 60_000)).toBe(1);87    expect(smoothingAlpha(swapStart + 90_000, swapStart, 60_000)).toBe(1);88  });8990  it("blends already-evaluated values linearly", () => {91    expect(blendValues(10, 20, swapStart, swapStart, 60_000)).toBe(10);92    expect(blendValues(10, 20, swapStart + 30_000, swapStart, 60_000)).toBeCloseTo(15, 12);93    expect(blendValues(10, 20, swapStart + 60_000, swapStart, 60_000)).toBe(20);94  });95});96