From 12e2d365274c539e0d730f78ec86ab60bace75c4 Mon Sep 17 00:00:00 2001 From: Indiana Date: Tue, 28 Jul 2026 13:36:24 +0000 Subject: [PATCH] refactor: extract the shared rolling-baseline core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BleFieldCore and MagFieldCore had converged on the same class: same time-aware EMA, same warm-up gate, same "deviation past a threshold is an event" shape, differing only in constants and field names. Three copies of the alpha derivation existed across the sensor libs. lib/baseline.ts now owns it. Both wrappers keep their own public types (`.rssi`, `.magnitude`) so nothing downstream changed — which is what let all 26 existing tests pass completely unmodified against the refactor. That was the point of doing it this way: if the tests had needed editing, the refactor would have been changing behaviour rather than removing duplication. coldSpot.ts deliberately does NOT adopt this. It threads immutable state through pure functions so a whole session's narrative can be replayed in a test without a clock — a different and equally valid shape. Collapsing the two would force one into a style that doesn't fit it, which is how deduplication turns into damage. 355 frontend tests pass unchanged; i18n parity gate passes. Co-Authored-By: Claude Opus 5 --- frontend/src/lib/baseline.ts | 117 +++++++++++++++++++++++++++++++ frontend/src/lib/bluetooth.ts | 55 +++++---------- frontend/src/lib/magnetometer.ts | 47 +++++-------- 3 files changed, 152 insertions(+), 67 deletions(-) create mode 100644 frontend/src/lib/baseline.ts diff --git a/frontend/src/lib/baseline.ts b/frontend/src/lib/baseline.ts new file mode 100644 index 0000000..49851fd --- /dev/null +++ b/frontend/src/lib/baseline.ts @@ -0,0 +1,117 @@ +// Shared rolling-baseline core for threshold sensors. +// +// Extracted because BleFieldCore and MagFieldCore had become the same class +// with different constants and field names — same time-aware EMA, same +// warm-up gate, same "deviation past a threshold is an event" shape. One +// implementation means one place to get the time-awareness right, and one +// place to fix it when it's wrong. +// +// Why the EMA is time-aware rather than per-sample: real sensors do not +// report on a fixed schedule. BLE advertisement intervals vary and the OS +// coalesces them; a magnetometer can be throttled when the page is +// backgrounded; a paired device can drop offline and return. A fixed +// per-sample alpha would weight a tight burst and a two-minute gap +// identically, which is wrong in both directions — it drags the baseline +// far too slowly across a gap (as if a hundred readings' worth of recency +// happened at once) and far too eagerly through a burst. +// +// The exponential-decay form degrades gracefully at both extremes: a long +// gap pushes alpha toward 1 (the old baseline is stale, trust the new +// reading), a rapid burst toward 0 (barely move at all). +// +// coldSpot.ts deliberately does NOT use this: it threads immutable state +// through pure functions so a whole session's narrative can be replayed in +// a test without a clock, which is a different and equally valid shape. +// Collapsing the two would mean forcing one of them into a style that +// doesn't fit it. + +export type BaselineEvent = { + /** Signed deviation from the baseline as it stood *before* this reading — + * i.e. "how surprising was this", not "how far is the baseline now". */ + deviation: number + /** |deviation| normalised against `threshold * 3`, clamped to 0..1. */ + severity: number + /** The raw reading that produced the event. */ + value: number + at: number +} + +export type ThresholdBaselineOptions = { + /** Deviation magnitude that counts as an event, in the sensor's own unit. */ + threshold: number + /** EMA time constant, ms. */ + tauMs: number + /** Readings folded in before deviations are trusted. A baseline has to + * mean something before a difference from it does. */ + minSamples: number +} + +/** + * Tracks a rolling baseline and reports readings that deviate far enough + * from it to count as events. + * + * Symmetric on purpose: a signal that strengthens is as much an event as + * one that weakens. A body can attenuate a BLE path *or* reflect into it, + * and ferrous mass can shield a magnetic field *or* add to it — a + * one-sided detector would miss half of what really happens. + */ +export class ThresholdBaseline { + private mean: number | null = null + private lastAt: number | null = null + private samples = 0 + + constructor(private readonly opts: ThresholdBaselineOptions) {} + + get baseline(): number | null { + return this.mean + } + + get warm(): boolean { + return this.samples >= this.opts.minSamples + } + + /** + * Fold one reading in. Returns an event when the deviation clears the + * threshold and the baseline is warm, else null. + * + * Non-finite input (a garbled packet, a sensor returning NaN) is a no-op + * rather than a crash or — worse — a poisoned baseline that would then + * misreport every subsequent reading. + */ + push(value: number, atMs: number): BaselineEvent | null { + if (!Number.isFinite(value) || !Number.isFinite(atMs)) return null + + if (this.mean === null) { + this.mean = value + this.lastAt = atMs + this.samples = 1 + return null + } + + const deviation = value - this.mean + const warm = this.warm + + // Guard against zero/negative dt (duplicate timestamps, clock skew, two + // readings in one tick) with a flat fallback rather than dividing by an + // elapsed time that isn't trustworthy. + const dt = this.lastAt === null ? 0 : atMs - this.lastAt + const alpha = dt > 0 ? 1 - Math.exp(-dt / this.opts.tauMs) : 0.15 + this.mean = this.mean + alpha * deviation + this.lastAt = atMs + this.samples++ + + if (!warm || Math.abs(deviation) < this.opts.threshold) return null + return { + deviation, + severity: Math.min(1, Math.abs(deviation) / (this.opts.threshold * 3)), + value, + at: atMs, + } + } + + reset(): void { + this.mean = null + this.lastAt = null + this.samples = 0 + } +} diff --git a/frontend/src/lib/bluetooth.ts b/frontend/src/lib/bluetooth.ts index 173227b..bdda7e9 100644 --- a/frontend/src/lib/bluetooth.ts +++ b/frontend/src/lib/bluetooth.ts @@ -46,6 +46,8 @@ function bluetoothApi(): BluetoothLike | null { return nav.bluetooth ?? null } +import { ThresholdBaseline } from './baseline' + export type BleReading = { /** Received signal strength, dBm. Typically -30 (touching) to -100 (far). */ rssi: number @@ -96,55 +98,36 @@ export function isSupported(): boolean { * Pure and separately testable: no Bluetooth objects appear in here. */ export class BleFieldCore { - private mean: number | null = null - private lastAt: number | null = null - private samples = 0 + private readonly core = new ThresholdBaseline({ + threshold: DISTURBANCE_DB, + tauMs: BASELINE_TAU_MS, + minSamples: MIN_SAMPLES, + }) get baseline(): number | null { - return this.mean + return this.core.baseline } get warm(): boolean { - return this.samples >= MIN_SAMPLES + return this.core.warm } - /** - * Fold one reading in. Returns a disturbance when the deviation clears - * the threshold and the baseline is warm, else null. Non-finite input is - * a no-op rather than a crash or a poisoned baseline. - */ + /** Fold one reading in; see ThresholdBaseline.push. Returns a + * BLE-flavoured disturbance so callers keep reading `.rssi` rather than a + * generic `.value`. */ push(rssi: number, atMs: number): BleDisturbance | null { - if (!Number.isFinite(rssi) || !Number.isFinite(atMs)) return null - - if (this.mean === null) { - this.mean = rssi - this.lastAt = atMs - this.samples = 1 - return null - } - - const deviation = rssi - this.mean - const warm = this.warm - - const dt = this.lastAt === null ? 0 : atMs - this.lastAt - const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15 - this.mean = this.mean + alpha * deviation - this.lastAt = atMs - this.samples++ - - if (!warm || Math.abs(deviation) < DISTURBANCE_DB) return null + const event = this.core.push(rssi, atMs) + if (!event) return null return { - deviation, - severity: Math.min(1, Math.abs(deviation) / (DISTURBANCE_DB * 3)), - rssi, - at: atMs, + deviation: event.deviation, + severity: event.severity, + rssi: event.value, + at: event.at, } } reset(): void { - this.mean = null - this.lastAt = null - this.samples = 0 + this.core.reset() } } diff --git a/frontend/src/lib/magnetometer.ts b/frontend/src/lib/magnetometer.ts index cbc9454..c974d30 100644 --- a/frontend/src/lib/magnetometer.ts +++ b/frontend/src/lib/magnetometer.ts @@ -25,6 +25,8 @@ // rolling-EMA-baseline shape used by coldSpot.ts and bluetooth.ts applies // here too. +import { ThresholdBaseline } from './baseline' + export type MagReading = { /** Field magnitude in microtesla, sqrt(x^2 + y^2 + z^2). */ magnitude: number @@ -76,50 +78,33 @@ export function isSupported(): boolean { * testable — no Sensor objects in here. */ export class MagFieldCore { - private mean: number | null = null - private lastAt: number | null = null - private samples = 0 + private readonly core = new ThresholdBaseline({ + threshold: SPIKE_UT, + tauMs: BASELINE_TAU_MS, + minSamples: MIN_SAMPLES, + }) get baseline(): number | null { - return this.mean + return this.core.baseline } get warm(): boolean { - return this.samples >= MIN_SAMPLES + return this.core.warm } push(magnitude: number, atMs: number): MagAnomaly | null { - if (!Number.isFinite(magnitude) || !Number.isFinite(atMs)) return null - - if (this.mean === null) { - this.mean = magnitude - this.lastAt = atMs - this.samples = 1 - return null - } - - const deviation = magnitude - this.mean - const warm = this.warm - - const dt = this.lastAt === null ? 0 : atMs - this.lastAt - const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15 - this.mean = this.mean + alpha * deviation - this.lastAt = atMs - this.samples++ - - if (!warm || Math.abs(deviation) < SPIKE_UT) return null + const event = this.core.push(magnitude, atMs) + if (!event) return null return { - deviation, - severity: Math.min(1, Math.abs(deviation) / (SPIKE_UT * 3)), - magnitude, - at: atMs, + deviation: event.deviation, + severity: event.severity, + magnitude: event.value, + at: event.at, } } reset(): void { - this.mean = null - this.lastAt = null - this.samples = 0 + this.core.reset() } }