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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user