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:
Indiana
2026-07-24 22:00:35 +00:00
parent 7ebedf363b
commit 917cd7f89e
8 changed files with 1469 additions and 18 deletions

View 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
}