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:
Indiana
2026-07-28 13:36:24 +00:00
parent cf602ab3de
commit 12e2d36527
3 changed files with 152 additions and 67 deletions

View File

@@ -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()
}
}