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%
|
// 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);
|
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.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 ————— */
|
/* ————— E5 — independent axes ————— */
|
||||||
|
|||||||
Reference in New Issue
Block a user