diff --git a/web/src/guidance/eta.ts b/web/src/guidance/eta.ts new file mode 100644 index 0000000..5051f50 --- /dev/null +++ b/web/src/guidance/eta.ts @@ -0,0 +1,86 @@ +/* + * eta.ts — the honest ETA: EMA smoothing + a display deadband (D-07). + * ---------------------------------------------------------------------- + * Guidance re-derives remaining time/distance from the ROUTE MODEL + the + * snapped progress on every throttled fix. Raw per-fix values wobble (GPS + * noise, speed-class steps) — an unsmoothed clock visibly whiplashes and a + * whiplashing instrument is a lying instrument. Two named mechanisms: + * + * EMA (EMA_ETA_ALPHA 0.25/fix) — per-axis exponential smoothing; the first + * update SEEDS directly (no smoothing up from zero). + * + * Deadband — the displayed value only refreshes when the smoothed value + * has moved meaningfully: ±1 min on time (DEADBAND_MIN_S), ±5% of + * remaining on distance (DEADBAND_REMAINING_PCT). The axes are + * INDEPENDENT: a time jump refreshes the clock without touching the + * metres and vice versa. + * + * D-07's law is structural here: updateEta consumes NUMBERS — the caller's + * remainingS/remainingM computed from the route model and projection — so a + * raw fix-to-destination distance cannot enter by accident. There is no + * Fix type in this module, no console, no storage, no network (gated by + * the eta suite's structural case). + * + * composeEtaClock renders the UI-SPEC instrument format: the 24-hour + * HH:MM arrival wall-clock (now + displayed eta), identical in en/ms. + */ + +/** Per-fix exponential smoothing factor (D-07; replay-tuned). */ +export const EMA_ETA_ALPHA = 0.25; +/** Display refresh deadband on remaining time (the ±1 min rule). */ +export const DEADBAND_MIN_S = 60; +/** Display refresh deadband on remaining distance (the ±5% rule). */ +export const DEADBAND_REMAINING_PCT = 0.05; + +/** One honest-ETA cell: raw smoothed values + what the display shows. */ +export interface EtaState { + /** EMA-smoothed remaining seconds. */ + etaS: number; + /** EMA-smoothed remaining meters. */ + remainingM: number; + /** What the instrument shows (deadband-held). */ + displayedEtaS: number; + displayedRemainingM: number; +} + +/** + * Fold one remaining-time/distance pair into the ETA state. + * + * `remainingS`/`remainingM` come from the caller's route-model math + * (projection + step durations) — never from a raw fix-to-destination + * measurement (D-07). First call seeds everything; afterwards the EMA + * smooths per axis and the display refreshes only past that axis's + * deadband (either bound passes -> THAT axis refreshes; axes independent). + */ +export function updateEta(prev: EtaState | null, remainingS: number, remainingM: number): EtaState { + if (prev === null) { + return { + etaS: remainingS, + remainingM, + displayedEtaS: remainingS, + displayedRemainingM: remainingM, + }; + } + const a = EMA_ETA_ALPHA; + const etaS = a * remainingS + (1 - a) * prev.etaS; + const remaining = a * remainingM + (1 - a) * prev.remainingM; + const displayedEtaS = + Math.abs(etaS - prev.displayedEtaS) >= DEADBAND_MIN_S ? etaS : prev.displayedEtaS; + const displayedRemainingM = + Math.abs(remaining - prev.displayedRemainingM) >= DEADBAND_REMAINING_PCT * remainingM + ? remaining + : prev.displayedRemainingM; + return { etaS, remainingM: remaining, displayedEtaS, displayedRemainingM }; +} + +/** + * The instrument clock: the 24-hour HH:MM wall time `etaS` seconds from + * `nowMs` (the UI-SPEC arrival format; identical in en/ms). Local time — + * the driver's clock, not the server's. + */ +export function composeEtaClock(etaS: number, nowMs: number): string { + const at = new Date(nowMs + etaS * 1000); + const hh = String(at.getHours()).padStart(2, "0"); + const mm = String(at.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} diff --git a/web/test/guidance-eta.test.mjs b/web/test/guidance-eta.test.mjs index d77d995..203a35a 100644 --- a/web/test/guidance-eta.test.mjs +++ b/web/test/guidance-eta.test.mjs @@ -78,7 +78,12 @@ test("E4 a genuine 5-minute drop updates within 2 fixes and converges to the tar // alpha 0.25 convergence: (1 - 0.25)^n < 0.1 at n = 9 — 12 fixes lands inside 10% for (let i = 0; i < 12; i++) s = updateEta(s, 300, 10000); assert.ok(Math.abs(s.etaS - 300) <= 30, `smoothed converges to the drop (got ${s.etaS})`); - assert.ok(Math.abs(s.displayedEtaS - 300) <= 30, `display converges too (got ${s.displayedEtaS})`); + // the display trails the converged value by at most the deadband — its + // whole contract (and why the instrument never whiplashes) + assert.ok( + Math.abs(s.displayedEtaS - s.etaS) <= DEADBAND_MIN_S, + `display within one deadband of the smoothed truth (got ${s.displayedEtaS} vs ${s.etaS})`, + ); }); /* ————— E5 — independent axes ————— */