feat: Cold Spot Detector / Atmospheric Disturbance Index on Device Bay
Real-time anomaly visualization for temperature/pressure readings on the device-feed dashboard, folklore's two most iconic paranormal markers: sudden cold spots and rapid barometric swings. - lib/coldSpot.ts: pure, directly-testable rolling-baseline tracker (time-aware EMA, since hardware doesn't report on a fixed schedule), cold-spot and pressure-anomaly classifiers, and a composite Atmospheric Disturbance Index that rewards correlated anomalies (a lone signal caps at 50/100; only both deviating together can reach 100) — modeled on evilMeter.ts's threaded-state pattern. - components/ColdSpotPanel.tsx/.css: frost treatment + sparkline for temperature, ripple treatment for pressure, and a crescent-arc composite gauge (GhostLog's evil-meter gauge as the visual family reference) that only appears once a device has reported both sensors. - DevicesPage.tsx: owns per-device baseline state, feeds it from `reading` frames, falls back to the existing generic row for any non-numeric temperature/pressure value. 26 new coldSpot.test.ts cases (warm-up, genuine vs. fluctuation, correlated-vs-solo index, gappy/out-of-order data) and 7 new DevicesPage integration tests. Full suite: 302/302 passing, tsc clean, i18n coverage clean (en/es). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
316
frontend/src/lib/coldSpot.ts
Normal file
316
frontend/src/lib/coldSpot.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
// Cold Spot Detector / Atmospheric Disturbance Index — pure functions that
|
||||
// turn a stream of temperature and pressure readings from the device feed
|
||||
// into "is this a paranormal-flavored anomaly" beliefs, modeled directly on
|
||||
// evilMeter.ts's pattern: small immutable state structs threaded through by
|
||||
// the caller (DevicesPage), not hidden global mutable history. Same reason
|
||||
// as evilMeter — every step is a pure function of (prior state, new
|
||||
// reading), so the whole narrative ("baseline warms up, then a real dip
|
||||
// registers, then it fades back to normal") is directly unit-testable
|
||||
// without a component or a fake clock driving React effects.
|
||||
//
|
||||
// Real paranormal folklore's two most iconic markers, and why each gets its
|
||||
// own detector:
|
||||
// - "cold spots": a sudden, localized temperature drop.
|
||||
// - "the air felt heavy": a rapid barometric pressure swing, in either
|
||||
// direction — investigators report both a sudden press before an event
|
||||
// and a sudden release after, so unlike the cold spot (which is always
|
||||
// a *drop*), the pressure anomaly is symmetric.
|
||||
//
|
||||
// Both detectors share one shape: a rolling baseline that adapts to slow,
|
||||
// legitimate drift (HVAC cycling, a weather front moving through) but gets
|
||||
// "surprised" by a sudden swing away from it. See `applyReading` below for
|
||||
// why that's an EMA keyed on elapsed wall-clock time rather than sample
|
||||
// count — real hardware does not report on a fixed schedule.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared baseline core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BaselineState = {
|
||||
/** Exponential-moving-average baseline; null until the first reading. */
|
||||
mean: number | null
|
||||
/** Readings folded in so far (uncapped — only used to gate warm-up). */
|
||||
sampleCount: number
|
||||
/** Epoch ms of the last reading folded in; null until the first. */
|
||||
lastAt: number | null
|
||||
}
|
||||
|
||||
export type BaselineConfig = {
|
||||
/** EMA time constant, in ms — how much recent wall-clock time the
|
||||
* baseline "remembers". A larger tau means the baseline adapts more
|
||||
* slowly, so a sudden swing stands out sharply against it, while genuine
|
||||
* slow drift (over many multiples of tau) still gets absorbed as the new
|
||||
* normal instead of registering as a standing anomaly forever. */
|
||||
tauMs: number
|
||||
/** Minimum folded samples before a deviation is trusted for
|
||||
* classification. The very first reading always has `deviation: null`
|
||||
* (there is nothing to deviate from yet) regardless of this value — this
|
||||
* guards the next couple of readings too, before the EMA has had any
|
||||
* real chance to average out sensor noise. */
|
||||
minSamples: number
|
||||
}
|
||||
|
||||
export type BaselineUpdate = {
|
||||
state: BaselineState
|
||||
/** Signed deviation of this reading from the *pre-update* baseline —
|
||||
* i.e. "how surprising was this reading", not "how far is the baseline
|
||||
* now from this reading". Null until the baseline has a first sample. */
|
||||
deviation: number | null
|
||||
/** True once `minSamples` readings have been folded into the baseline
|
||||
* prior to this one. Before that, `deviation` exists but should not be
|
||||
* used to classify an anomaly (warm-up). */
|
||||
warm: boolean
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
export function initBaseline(): BaselineState {
|
||||
return { mean: null, sampleCount: 0, lastAt: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one reading into a rolling baseline and reports how much it
|
||||
* deviated from the baseline *as it stood before this reading*.
|
||||
*
|
||||
* Time-aware EMA: alpha is derived from the elapsed wall-clock time since
|
||||
* the last reading (`1 - exp(-dt/tau)`), not from "one more sample".
|
||||
* Real ESP32 sensor nodes do not report on a perfectly fixed schedule —
|
||||
* gaps of seconds to many minutes are normal (a device can drop offline
|
||||
* and reconnect, or simply have a slower sensor poll loop for one
|
||||
* sensor_type than another). A fixed per-sample alpha would drag the
|
||||
* baseline unrealistically slowly across a long gap (as if a hundred
|
||||
* readings' worth of "recency" happened in one step) or snap it too
|
||||
* eagerly across a tight burst. The exponential-decay form degrades
|
||||
* gracefully at both extremes: a long gap makes alpha approach 1 (the old
|
||||
* baseline is stale, trust the new reading almost completely); a rapid
|
||||
* burst makes alpha approach 0 (barely move the baseline at all).
|
||||
*
|
||||
* Non-finite input (NaN/Infinity — a garbled reading) is a defensive
|
||||
* no-op: the state is returned unchanged with `deviation: null`, matching
|
||||
* the rest of this codebase's rule that a malformed sensor payload must
|
||||
* never corrupt state or throw (see DevicesPage.tsx's formatSensorValue).
|
||||
*/
|
||||
export function applyReading(
|
||||
state: BaselineState,
|
||||
config: BaselineConfig,
|
||||
value: number,
|
||||
atMs: number,
|
||||
): BaselineUpdate {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(atMs)) {
|
||||
return { state, deviation: null, warm: state.sampleCount >= config.minSamples }
|
||||
}
|
||||
|
||||
if (state.mean === null) {
|
||||
return {
|
||||
state: { mean: value, sampleCount: 1, lastAt: atMs },
|
||||
deviation: null,
|
||||
warm: false,
|
||||
}
|
||||
}
|
||||
|
||||
const deviation = value - state.mean
|
||||
const warm = state.sampleCount >= config.minSamples
|
||||
|
||||
// Guard against zero/negative/out-of-order dt (duplicate timestamps,
|
||||
// clock skew, or two readings racing in the same tick) with a flat
|
||||
// fallback step rather than dividing by an elapsed time that isn't
|
||||
// trustworthy.
|
||||
const dtMs = state.lastAt === null ? 0 : atMs - state.lastAt
|
||||
const alpha = dtMs > 0 ? 1 - Math.exp(-dtMs / config.tauMs) : 0.15
|
||||
const nextMean = state.mean + alpha * deviation
|
||||
|
||||
return {
|
||||
state: {
|
||||
mean: nextMean,
|
||||
sampleCount: state.sampleCount + 1,
|
||||
lastAt: atMs,
|
||||
},
|
||||
deviation,
|
||||
warm,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temperature / cold spot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Indoor ambient temperature drifts slowly under normal conditions (HVAC
|
||||
// cycling over ~10-20 minutes, sun moving across a window over tens of
|
||||
// minutes). A real "cold spot" claim is a rapid, localized dip over
|
||||
// seconds to at most a minute or two. A 3-minute time constant means the
|
||||
// baseline tracks legitimate slow drift (a held new temperature stops
|
||||
// looking anomalous after a few minutes) while a sudden single-reading
|
||||
// drop still registers as a sharp deviation the instant it happens.
|
||||
export const TEMP_BASELINE_TAU_MS = 3 * 60_000
|
||||
|
||||
// Three folded samples before trusting a deviation — enough that the
|
||||
// baseline isn't just "whatever the second reading happened to be", but
|
||||
// few enough that the dashboard reacts within a handful of readings
|
||||
// rather than a long silent warm-up.
|
||||
export const TEMP_BASELINE_MIN_SAMPLES = 3
|
||||
|
||||
// Magnitude reasoning: cheap BME280-class sensors run ~+-0.5C accuracy,
|
||||
// and ordinary room noise/HVAC cycling produces on the order of +-0.5 to
|
||||
// 1C of fluctuation (this mirrors the backend's own generic noise floor
|
||||
// for temperature in backend/app/device_anomaly.py, +-0.8C). A real,
|
||||
// noticeable "cold spot" — not the dramatic 10-15F chill right next to an
|
||||
// open freezer that ghost-hunting shows love to dramatize, but a
|
||||
// meaningful localized dip for an ordinary room with a rolling baseline —
|
||||
// needs to clear that noise band with room to spare. 1.5C (~2.7F) below
|
||||
// baseline is roughly double the sensor's own noise floor: big enough
|
||||
// that it isn't "the HVAC kicked on", small enough to be an achievable,
|
||||
// testable signal rather than requiring an extreme outlier.
|
||||
export const COLD_SPOT_DROP_C = 1.5
|
||||
|
||||
export type TemperatureBaselineState = BaselineState
|
||||
|
||||
export function initTemperatureBaseline(): TemperatureBaselineState {
|
||||
return initBaseline()
|
||||
}
|
||||
|
||||
export type TemperatureReadingResult = {
|
||||
state: TemperatureBaselineState
|
||||
deviation: number | null
|
||||
isColdSpot: boolean
|
||||
/** 0..1 — ramps from just-over-0 at the classification threshold to 1 at
|
||||
* 3x the threshold, so the composite index and any visual intensity has
|
||||
* room to distinguish "barely a cold spot" from "dramatic dip" instead
|
||||
* of being a flat on/off switch. */
|
||||
severity: number
|
||||
}
|
||||
|
||||
export function applyTemperatureReading(
|
||||
state: TemperatureBaselineState,
|
||||
value: number,
|
||||
atMs: number,
|
||||
): TemperatureReadingResult {
|
||||
const { state: next, deviation, warm } = applyReading(
|
||||
state,
|
||||
{ tauMs: TEMP_BASELINE_TAU_MS, minSamples: TEMP_BASELINE_MIN_SAMPLES },
|
||||
value,
|
||||
atMs,
|
||||
)
|
||||
const drop = deviation !== null ? Math.max(0, -deviation) : 0
|
||||
const isColdSpot = warm && drop >= COLD_SPOT_DROP_C
|
||||
const severity = clamp01(drop / (COLD_SPOT_DROP_C * 3))
|
||||
return { state: next, deviation, isColdSpot, severity }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pressure / atmospheric anomaly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Genuine weather-driven barometric change is gradual — even an active
|
||||
// storm front typically moves pressure by only ~1-3 hPa/hour. A longer
|
||||
// (5-minute) time constant lets that kind of trend get absorbed into the
|
||||
// baseline as normal drift, while a fast localized swing — the "heavy
|
||||
// air" folklore marker — still reads as a sharp spike against the
|
||||
// slower-moving baseline.
|
||||
export const PRESSURE_BASELINE_TAU_MS = 5 * 60_000
|
||||
|
||||
export const PRESSURE_BASELINE_MIN_SAMPLES = 3
|
||||
|
||||
// Magnitude reasoning: BME280-class pressure accuracy is ~+-1hPa, and
|
||||
// routine short-term drift (not weather, just sensor + micro-drafts) sits
|
||||
// well under 1hPa over a few minutes. A rapid 2hPa swing in either
|
||||
// direction is roughly double that noise floor within the rolling
|
||||
// baseline's own timescale — comparable in felt magnitude to the ear-pop
|
||||
// from an elevator ride of ~15-20 floors happening over a couple of
|
||||
// minutes indoors, a real "the atmosphere shifted" moment rather than
|
||||
// sensor jitter.
|
||||
export const PRESSURE_SWING_HPA = 2.0
|
||||
|
||||
export type PressureBaselineState = BaselineState
|
||||
|
||||
export function initPressureBaseline(): PressureBaselineState {
|
||||
return initBaseline()
|
||||
}
|
||||
|
||||
export type PressureReadingResult = {
|
||||
state: PressureBaselineState
|
||||
deviation: number | null
|
||||
isPressureAnomaly: boolean
|
||||
/** 0..1, same ramp shape as temperature's severity. */
|
||||
severity: number
|
||||
/** Which way the swing went; meaningless (but harmless) when
|
||||
* `isPressureAnomaly` is false. */
|
||||
direction: 'rise' | 'drop'
|
||||
}
|
||||
|
||||
export function applyPressureReading(
|
||||
state: PressureBaselineState,
|
||||
value: number,
|
||||
atMs: number,
|
||||
): PressureReadingResult {
|
||||
const { state: next, deviation, warm } = applyReading(
|
||||
state,
|
||||
{ tauMs: PRESSURE_BASELINE_TAU_MS, minSamples: PRESSURE_BASELINE_MIN_SAMPLES },
|
||||
value,
|
||||
atMs,
|
||||
)
|
||||
const swing = deviation !== null ? Math.abs(deviation) : 0
|
||||
const isPressureAnomaly = warm && swing >= PRESSURE_SWING_HPA
|
||||
const severity = clamp01(swing / (PRESSURE_SWING_HPA * 3))
|
||||
const direction: 'rise' | 'drop' = deviation !== null && deviation < 0 ? 'drop' : 'rise'
|
||||
return { state: next, deviation, isPressureAnomaly, severity, direction }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite Atmospheric Disturbance Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Real ghost-hunting methodology (for whatever that's worth as a design
|
||||
// reference) treats corroborating readings across independent
|
||||
// instruments as the strong signal — one sensor twitching is an anomaly;
|
||||
// two independent sensors twitching *at the same time* is an event. The
|
||||
// index encodes that directly: each signal alone can only push the score
|
||||
// up to half of the scale (`0.5 * severity` each), and a multiplicative
|
||||
// cross term (`0.5 * tempSeverity * pressureSeverity`) — zero unless BOTH
|
||||
// severities are nonzero — is the only way into the top half. A single
|
||||
// maxed-out signal tops out at 50; only a genuinely correlated event (both
|
||||
// severities elevated together) can approach 100.
|
||||
const SOLO_WEIGHT = 0.5
|
||||
const CORRELATION_WEIGHT = 0.5
|
||||
|
||||
export function disturbanceIndex(tempSeverity: number, pressureSeverity: number): number {
|
||||
const t = clamp01(tempSeverity)
|
||||
const p = clamp01(pressureSeverity)
|
||||
const raw = SOLO_WEIGHT * t + SOLO_WEIGHT * p + CORRELATION_WEIGHT * t * p
|
||||
return Math.round(clamp01(raw) * 100)
|
||||
}
|
||||
|
||||
/** Human-readable label for the composite index, mirroring
|
||||
* evilMeterLabel's un-translated plain-English HUD-readout convention
|
||||
* (GhostLog.tsx does not run that label through t() either). `correlated`
|
||||
* should be `isColdSpot && isPressureAnomaly` from the caller — the label
|
||||
* calls out convergence explicitly rather than leaving it implicit in a
|
||||
* high number. */
|
||||
export function disturbanceLabel(index: number, correlated: boolean): string {
|
||||
if (correlated && index >= 40) return 'converging anomaly'
|
||||
if (index >= 70) return 'severe disturbance'
|
||||
if (index >= 40) return 'disturbance rising'
|
||||
if (index >= 15) return 'faint disturbance'
|
||||
return 'calm'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sparkline sample history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How many recent temperature samples the sparkline keeps — enough to
|
||||
* show the shape of a dip-and-recovery, bounded so a chatty device can't
|
||||
* grow a device row's memory without limit (same TELL_CAP-style bound as
|
||||
* GhostLog.tsx). */
|
||||
export const SPARKLINE_SAMPLE_CAP = 24
|
||||
|
||||
export function pushSample(
|
||||
history: readonly number[],
|
||||
value: number,
|
||||
cap: number = SPARKLINE_SAMPLE_CAP,
|
||||
): number[] {
|
||||
if (!Number.isFinite(value)) return [...history]
|
||||
const next = [...history, value]
|
||||
return next.length > cap ? next.slice(next.length - cap) : next
|
||||
}
|
||||
Reference in New Issue
Block a user