/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/counter/src/value.ts * Purpose: Pure evaluation of a CounterModel — value(model, t) and rateAt(model, t), bit-identical client/server */ import { type CounterModel, type Harmonic, type RateFunction, DAY_SECONDS, SEASONAL_EPOCH_MS, WEEK_SECONDS, YEAR_SECONDS, parseIsoUtc, } from "./counter-model.js"; import { pchipDerivative, pchipEvaluate, pchipSlopes } from "./pchip.js"; /** Parsed spline data is cached per rateFn object identity — evaluation stays pure. */ const splineCache = new WeakMap< object, { xs: number[]; ys: number[]; slopes: number[] } >(); function splineData(rateFn: Extract): { xs: number[]; ys: number[]; slopes: number[]; } { const cached = splineCache.get(rateFn); if (cached) return cached; const xs = rateFn.knots.map(([t]) => parseIsoUtc(t)); const ys = rateFn.knots.map(([, v]) => v); const data = { xs, ys, slopes: pchipSlopes(xs, ys) }; splineCache.set(rateFn, data); return data; } function harmonicOmega(h: Harmonic): number { const period = h.period === "year" ? YEAR_SECONDS : h.period === "week" ? WEEK_SECONDS : DAY_SECONDS; return (2 * Math.PI * h.order) / period; } /** ∫ rate dt of one harmonic between two instants (seconds since the seasonal epoch). */ function harmonicIntegral(h: Harmonic, tau0: number, tau1: number): number { const omega = harmonicOmega(h); return (h.amplitude / omega) * (Math.sin(omega * tau1 + h.phase) - Math.sin(omega * tau0 + h.phase)); } function harmonicRate(h: Harmonic, tau: number): number { return h.amplitude * Math.cos(harmonicOmega(h) * tau + h.phase); } /** * Value of the counter at time t (ms since Unix epoch, UTC). * Time is always a parameter — this package never reads the system clock. */ export function counterValue(model: CounterModel, tMs: number): number { const anchorMs = parseIsoUtc(model.anchorTime); const rateFn = model.rateFn; switch (rateFn.kind) { case "linear": return model.anchorValue + (rateFn.perSecond * (tMs - anchorMs)) / 1000; case "piecewise": { // Signed integral of the piecewise-constant rate from anchorTime to t. const froms = rateFn.segments.map((s) => parseIsoUtc(s.from)); let acc = model.anchorValue; const [a, b] = anchorMs <= tMs ? [anchorMs, tMs] : [tMs, anchorMs]; let integral = 0; for (let i = 0; i < rateFn.segments.length; i++) { const segStart = froms[i]!; const segEnd = i + 1 < froms.length ? froms[i + 1]! : Infinity; const lo = Math.max(a, segStart); const hi = Math.min(b, segEnd); if (hi > lo) integral += (rateFn.segments[i]!.perSecond * (hi - lo)) / 1000; } acc += anchorMs <= tMs ? integral : -integral; return acc; } case "seasonal": { const tau0 = (anchorMs - SEASONAL_EPOCH_MS) / 1000; const tau1 = (tMs - SEASONAL_EPOCH_MS) / 1000; let v = model.anchorValue + rateFn.base * (tau1 - tau0); for (const h of rateFn.harmonics) v += harmonicIntegral(h, tau0, tau1); return v; } case "spline": { const { xs, ys, slopes } = splineData(rateFn); return pchipEvaluate(xs, ys, slopes, tMs); } } } /** Instantaneous rate (base unit per second) at time t. */ export function rateAt(model: CounterModel, tMs: number): number { const rateFn = model.rateFn; switch (rateFn.kind) { case "linear": return rateFn.perSecond; case "piecewise": { let rate = rateFn.segments[0]?.perSecond ?? 0; for (const seg of rateFn.segments) { if (parseIsoUtc(seg.from) <= tMs) rate = seg.perSecond; else break; } return rate; } case "seasonal": { const tau = (tMs - SEASONAL_EPOCH_MS) / 1000; let r = rateFn.base; for (const h of rateFn.harmonics) r += harmonicRate(h, tau); return r; } case "spline": { const { xs, ys, slopes } = splineData(rateFn); // pchip derivative is per ms of x-axis; convert to per second. return pchipDerivative(xs, ys, slopes, tMs) * 1000; } } }