refactor: extract the shared rolling-baseline core
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 <noreply@anthropic.com>
This commit is contained in:
117
frontend/src/lib/baseline.ts
Normal file
117
frontend/src/lib/baseline.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user