// 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 // Gated by `warm` for the same reason isColdSpot is: pre-warm-up // deviations are against a baseline that hasn't had a real chance to // average out sensor noise, and severity feeds the composite disturbance // gauge directly (ColdSpotPanel.tsx), which has no boolean gate of its // own to catch an ungated value here. const severity = warm ? clamp01(drop / (COLD_SPOT_DROP_C * 3)) : 0 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 // See applyTemperatureReading's comment: gated by `warm` so the composite // disturbance gauge can't be driven by a pre-warm-up baseline swing. const severity = warm ? clamp01(swing / (PRESSURE_SWING_HPA * 3)) : 0 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 }