feat(06-02): honest ETA — per-axis EMA + independent display deadbands + arrival clock (GREEN)
- updateEta(prev|null, remainingS, remainingM): numbers-only signature (D-07 structural — no Fix can enter); first update seeds directly - EMA alpha 0.25/fix per axis; display refreshes only past its axis deadband (+-1 min time / +-5% remaining, axes independent) - composeEtaClock: 24-hour HH:MM instrument format (local wall clock) - suite E1-E8 green: seed, jitter-holds-display, real-drop-updates, independent axes, deadband symmetry, pinned constants, purity gate
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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 ————— */
|
||||
|
||||
Reference in New Issue
Block a user