+ )
+}
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 421b975..5ad3aff 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -474,6 +474,16 @@
"open": "feed live",
"unstable": "feed unstable…",
"closed": "feed offline"
+ },
+ "coldSpot": {
+ "callout": "COLD SPOT — {{deviation}} {{unit}} below baseline"
+ },
+ "pressureAnomaly": {
+ "callout": "PRESSURE ANOMALY — {{magnitude}} {{unit}} swing, atmosphere shifting"
+ },
+ "disturbance": {
+ "title": "ATMOSPHERIC DISTURBANCE",
+ "ariaLabel": "atmospheric disturbance index"
}
}
},
diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json
index 39aa4b4..70da8bc 100644
--- a/frontend/src/i18n/es.json
+++ b/frontend/src/i18n/es.json
@@ -474,6 +474,16 @@
"open": "transmisión en vivo",
"unstable": "transmisión inestable…",
"closed": "transmisión desconectada"
+ },
+ "coldSpot": {
+ "callout": "PUNTO FRÍO — {{deviation}} {{unit}} bajo la base"
+ },
+ "pressureAnomaly": {
+ "callout": "ANOMALÍA DE PRESIÓN — variación de {{magnitude}} {{unit}}, la atmósfera cambia"
+ },
+ "disturbance": {
+ "title": "PERTURBACIÓN ATMOSFÉRICA",
+ "ariaLabel": "índice de perturbación atmosférica"
}
}
},
diff --git a/frontend/src/lib/coldSpot.test.ts b/frontend/src/lib/coldSpot.test.ts
new file mode 100644
index 0000000..c392948
--- /dev/null
+++ b/frontend/src/lib/coldSpot.test.ts
@@ -0,0 +1,283 @@
+import { describe, expect, it } from 'vitest'
+import {
+ COLD_SPOT_DROP_C,
+ PRESSURE_SWING_HPA,
+ SPARKLINE_SAMPLE_CAP,
+ applyPressureReading,
+ applyReading,
+ applyTemperatureReading,
+ disturbanceIndex,
+ disturbanceLabel,
+ initBaseline,
+ initPressureBaseline,
+ initTemperatureBaseline,
+ pushSample,
+} from './coldSpot'
+
+const T0 = Date.parse('2026-07-23T10:00:00Z')
+const sec = (n: number) => n * 1000
+const min = (n: number) => n * 60_000
+
+describe('applyTemperatureReading — baseline warm-up behavior', () => {
+ it('the very first reading never has a deviation and is never a cold spot', () => {
+ const r = applyTemperatureReading(initTemperatureBaseline(), 5.0, T0)
+ expect(r.deviation).toBeNull()
+ expect(r.isColdSpot).toBe(false)
+ expect(r.state.mean).toBe(5.0)
+ })
+
+ it('does not flag a cold spot during warm-up even given a large early swing', () => {
+ let state = initTemperatureBaseline()
+ let r = applyTemperatureReading(state, 21.0, T0)
+ state = r.state
+ // second reading: a big drop, but baseline has only 1 sample folded in —
+ // not warm yet, must not false-positive.
+ r = applyTemperatureReading(state, 10.0, T0 + sec(30))
+ expect(r.isColdSpot).toBe(false)
+ state = r.state
+ // third reading: still not warm (sampleCount was 2 going in).
+ r = applyTemperatureReading(state, 21.0, T0 + sec(60))
+ expect(r.isColdSpot).toBe(false)
+ })
+})
+
+describe('applyTemperatureReading — genuine cold-spot sequence', () => {
+ it('classifies a real sudden dip once the baseline is warm', () => {
+ let state = initTemperatureBaseline()
+ let t = T0
+ // warm the baseline on a steady ~21C for a few readings, spaced well
+ // under the tau so the baseline settles near 21.
+ for (let i = 0; i < 4; i++) {
+ const r = applyTemperatureReading(state, 21.0, t)
+ state = r.state
+ t += sec(30)
+ }
+ // a real cold-spot event: a sharp drop far past the threshold.
+ const drop = applyTemperatureReading(state, 15.0, t)
+ expect(drop.isColdSpot).toBe(true)
+ expect(drop.deviation).toBeLessThan(-COLD_SPOT_DROP_C)
+ expect(drop.severity).toBeGreaterThan(0)
+ expect(drop.severity).toBeLessThanOrEqual(1)
+ })
+
+ it('severity saturates at 1 for a dramatic drop and stays within [0,1]', () => {
+ let state = initTemperatureBaseline()
+ let t = T0
+ for (let i = 0; i < 4; i++) {
+ const r = applyTemperatureReading(state, 20.0, t)
+ state = r.state
+ t += sec(20)
+ }
+ const drop = applyTemperatureReading(state, 5.0, t) // wildly cold
+ expect(drop.isColdSpot).toBe(true)
+ expect(drop.severity).toBe(1)
+ })
+})
+
+describe('applyTemperatureReading — normal fluctuation is NOT classified', () => {
+ it('never flags a cold spot for ordinary noise within the threshold band', () => {
+ let state = initTemperatureBaseline()
+ let t = T0
+ const noisyValues = [21.0, 21.2, 20.8, 21.1, 20.9, 21.3, 20.7, 21.0, 21.1, 20.9]
+ let anyColdSpot = false
+ for (const v of noisyValues) {
+ const r = applyTemperatureReading(state, v, t)
+ state = r.state
+ if (r.isColdSpot) anyColdSpot = true
+ t += sec(45)
+ }
+ expect(anyColdSpot).toBe(false)
+ })
+})
+
+describe('applyPressureReading — genuine pressure swing', () => {
+ it('classifies a rapid rise once warm', () => {
+ let state = initPressureBaseline()
+ let t = T0
+ for (let i = 0; i < 4; i++) {
+ const r = applyPressureReading(state, 1013.0, t)
+ state = r.state
+ t += sec(30)
+ }
+ const spike = applyPressureReading(state, 1013.0 + PRESSURE_SWING_HPA + 2.5, t)
+ expect(spike.isPressureAnomaly).toBe(true)
+ expect(spike.direction).toBe('rise')
+ })
+
+ it('classifies a rapid drop once warm', () => {
+ let state = initPressureBaseline()
+ let t = T0
+ for (let i = 0; i < 4; i++) {
+ const r = applyPressureReading(state, 1013.0, t)
+ state = r.state
+ t += sec(30)
+ }
+ const plunge = applyPressureReading(state, 1013.0 - PRESSURE_SWING_HPA - 2.5, t)
+ expect(plunge.isPressureAnomaly).toBe(true)
+ expect(plunge.direction).toBe('drop')
+ })
+
+ it('does not flag ordinary weather-scale drift as an anomaly', () => {
+ let state = initPressureBaseline()
+ let t = T0
+ let any = false
+ // a slow, small drift well under the threshold, spaced across minutes —
+ // representative of real weather movement, not a swing.
+ const values = [1013.0, 1013.1, 1013.15, 1013.05, 1013.2, 1013.1, 1013.25]
+ for (const v of values) {
+ const r = applyPressureReading(state, v, t)
+ state = r.state
+ if (r.isPressureAnomaly) any = true
+ t += min(2)
+ }
+ expect(any).toBe(false)
+ })
+})
+
+describe('disturbanceIndex — composite weighting', () => {
+ it('is zero when neither signal deviates', () => {
+ expect(disturbanceIndex(0, 0)).toBe(0)
+ })
+
+ it('a single maxed-out signal alone caps at half the scale', () => {
+ expect(disturbanceIndex(1, 0)).toBe(50)
+ expect(disturbanceIndex(0, 1)).toBe(50)
+ })
+
+ it('correlated anomalies push the index well past what either alone would reach', () => {
+ const soloTemp = disturbanceIndex(1, 0)
+ const soloPressure = disturbanceIndex(0, 1)
+ const both = disturbanceIndex(1, 1)
+ expect(both).toBeGreaterThan(soloTemp)
+ expect(both).toBeGreaterThan(soloPressure)
+ expect(both).toBe(100)
+ })
+
+ it('moderate correlated severities score higher than one maxed-out solo signal', () => {
+ // 0.5/0.5 correlated vs 1.0/0 solo: correlation term should make up
+ // the gap even though neither individual severity is as extreme.
+ const correlated = disturbanceIndex(0.5, 0.5)
+ const solo = disturbanceIndex(1, 0)
+ expect(correlated).toBeGreaterThan(solo)
+ })
+
+ it('stays within [0, 100] for out-of-range inputs', () => {
+ expect(disturbanceIndex(-1, 2)).toBeGreaterThanOrEqual(0)
+ expect(disturbanceIndex(-1, 2)).toBeLessThanOrEqual(100)
+ })
+})
+
+describe('disturbanceLabel', () => {
+ it('reads calm at the bottom of the scale', () => {
+ expect(disturbanceLabel(0, false)).toBe('calm')
+ expect(disturbanceLabel(10, false)).toBe('calm')
+ })
+
+ it('escalates through the uncorrelated bands', () => {
+ expect(disturbanceLabel(20, false)).toBe('faint disturbance')
+ expect(disturbanceLabel(50, false)).toBe('disturbance rising')
+ expect(disturbanceLabel(80, false)).toBe('severe disturbance')
+ })
+
+ it('calls out convergence explicitly once correlated and past the mid band', () => {
+ expect(disturbanceLabel(50, true)).toBe('converging anomaly')
+ expect(disturbanceLabel(90, true)).toBe('converging anomaly')
+ })
+
+ it('a correlated flag at a low index still reads by index alone', () => {
+ expect(disturbanceLabel(10, true)).toBe('calm')
+ })
+})
+
+describe('applyReading — sparse/gappy data resilience', () => {
+ it('handles a very long gap between readings without NaN, snapping close to the new value', () => {
+ const config = { tauMs: min(3), minSamples: 3 }
+ let state = initBaseline()
+ let r = applyReading(state, config, 21.0, T0)
+ state = r.state
+ // gap of 2 hours — far more than tau, device was offline / silent.
+ r = applyReading(state, config, 15.0, T0 + 2 * 60 * 60 * 1000)
+ expect(Number.isFinite(r.state.mean as number)).toBe(true)
+ expect(r.deviation).toBeCloseTo(-6, 5)
+ // alpha should be ~1 across such a long gap, so the new mean should
+ // land very close to the new reading.
+ expect(r.state.mean as number).toBeCloseTo(15.0, 1)
+ })
+
+ it('handles a zero-elapsed-time duplicate reading without throwing or producing NaN', () => {
+ const config = { tauMs: min(3), minSamples: 3 }
+ let state = initBaseline()
+ let r = applyReading(state, config, 21.0, T0)
+ state = r.state
+ expect(() => {
+ r = applyReading(state, config, 21.5, T0)
+ }).not.toThrow()
+ expect(Number.isFinite(r.state.mean as number)).toBe(true)
+ expect(Number.isFinite(r.deviation as number)).toBe(true)
+ })
+
+ it('handles an out-of-order (earlier) timestamp without throwing or producing NaN/negative dt issues', () => {
+ const config = { tauMs: min(3), minSamples: 3 }
+ let state = initBaseline()
+ let r = applyReading(state, config, 21.0, T0)
+ state = r.state
+ r = applyReading(state, config, 21.0, T0 - sec(60))
+ expect(Number.isFinite(r.state.mean as number)).toBe(true)
+ })
+
+ it('never lets a non-finite reading corrupt the baseline', () => {
+ const config = { tauMs: min(3), minSamples: 3 }
+ let state = initBaseline()
+ let r = applyReading(state, config, 21.0, T0)
+ state = r.state
+ r = applyReading(state, config, Number.NaN, T0 + sec(30))
+ expect(r.deviation).toBeNull()
+ expect(r.state).toEqual(state) // unchanged
+ r = applyReading(state, config, Number.POSITIVE_INFINITY, T0 + sec(30))
+ expect(r.deviation).toBeNull()
+ expect(r.state).toEqual(state)
+ })
+
+ it('gaps arriving irregularly (not on a fixed schedule) never crash and stay classification-consistent', () => {
+ let state = initTemperatureBaseline()
+ let t = T0
+ const gaps = [sec(1), sec(45), sec(3), min(6), sec(12), sec(90)]
+ for (const gap of gaps) {
+ t += gap
+ const r = applyTemperatureReading(state, 21.0 + (Math.random() - 0.5) * 0.4, t)
+ state = r.state
+ expect(Number.isFinite(state.mean as number)).toBe(true)
+ }
+ })
+})
+
+describe('pushSample', () => {
+ it('appends and does not mutate the input array', () => {
+ const history = [1, 2, 3]
+ const next = pushSample(history, 4)
+ expect(next).toEqual([1, 2, 3, 4])
+ expect(history).toEqual([1, 2, 3])
+ })
+
+ it('caps at the given length, dropping the oldest samples first', () => {
+ let history: number[] = []
+ for (let i = 0; i < 10; i++) {
+ history = pushSample(history, i, 5)
+ }
+ expect(history).toEqual([5, 6, 7, 8, 9])
+ })
+
+ it('defaults to SPARKLINE_SAMPLE_CAP when no cap is given', () => {
+ let history: number[] = []
+ for (let i = 0; i < SPARKLINE_SAMPLE_CAP + 10; i++) {
+ history = pushSample(history, i)
+ }
+ expect(history.length).toBe(SPARKLINE_SAMPLE_CAP)
+ expect(history[history.length - 1]).toBe(SPARKLINE_SAMPLE_CAP + 9)
+ })
+
+ it('ignores a non-finite sample rather than corrupting the sparkline', () => {
+ const history = [1, 2, 3]
+ expect(pushSample(history, Number.NaN)).toEqual([1, 2, 3])
+ })
+})
diff --git a/frontend/src/lib/coldSpot.ts b/frontend/src/lib/coldSpot.ts
new file mode 100644
index 0000000..cc87d1e
--- /dev/null
+++ b/frontend/src/lib/coldSpot.ts
@@ -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
+}
diff --git a/frontend/src/pages/DevicesPage.test.tsx b/frontend/src/pages/DevicesPage.test.tsx
index c57066a..c4ebc78 100644
--- a/frontend/src/pages/DevicesPage.test.tsx
+++ b/frontend/src/pages/DevicesPage.test.tsx
@@ -293,6 +293,190 @@ describe('DevicesPage — live dashboard', () => {
})
})
+describe('DevicesPage — Cold Spot Detector / Atmospheric Disturbance Index', () => {
+ beforeEach(() => {
+ mockFetch(async () => new Response('{}', { status: 200 }))
+ })
+
+ const T0 = Date.parse('2026-07-23T10:00:00Z')
+ const iso = (ms: number) => new Date(ms).toISOString()
+
+ function emitTemp(socket: FakeDeviceFeedSocket, deviceId: string, value: number, atMs: number) {
+ act(() => {
+ socket.emit({
+ type: 'reading',
+ device_id: deviceId,
+ sensor_type: 'temperature',
+ value,
+ unit: 'c',
+ metadata: {},
+ at: iso(atMs),
+ })
+ })
+ }
+
+ function emitPressure(socket: FakeDeviceFeedSocket, deviceId: string, value: number, atMs: number) {
+ act(() => {
+ socket.emit({
+ type: 'reading',
+ device_id: deviceId,
+ sensor_type: 'pressure',
+ value,
+ unit: 'hpa',
+ metadata: {},
+ at: iso(atMs),
+ })
+ })
+ }
+
+ it('renders a temperature-only device sensibly, with no cold-spot callout or composite gauge until warm', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({ type: 'devices', devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }] })
+ })
+ emitTemp(socket, 'd-1', 21.0, T0)
+
+ expect(screen.getByText('21')).toBeTruthy()
+ expect(screen.queryByTestId('cold-spot-callout')).toBeNull()
+ // composite gauge must not appear — pressure has never reported.
+ expect(screen.queryByTestId('disturbance-gauge')).toBeNull()
+ })
+
+ it('shows a cold-spot callout and frost treatment once a genuine cold-spot sequence lands', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({ type: 'devices', devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }] })
+ })
+
+ let t = T0
+ for (let i = 0; i < 4; i++) {
+ emitTemp(socket, 'd-1', 21.0, t)
+ t += 30_000
+ }
+ expect(screen.queryByTestId('cold-spot-callout')).toBeNull()
+
+ // a genuine sudden drop, well past the classification threshold.
+ emitTemp(socket, 'd-1', 15.0, t)
+
+ expect(screen.getByTestId('cold-spot-callout')).toBeTruthy()
+ expect(screen.getByText(/COLD SPOT/i)).toBeTruthy()
+ const card = screen.getByTestId('device-card-d-1')
+ expect(card.querySelector('.is-cold-spot')).toBeTruthy()
+ })
+
+ it('updates the temperature sparkline as new readings arrive', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({ type: 'devices', devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }] })
+ })
+ emitTemp(socket, 'd-1', 20.0, T0)
+ const card = screen.getByTestId('device-card-d-1')
+ let polyline = card.querySelector('.coldspot-sparkline-line') as SVGPolylineElement | null
+ expect(polyline).toBeTruthy()
+ const firstPoints = polyline!.getAttribute('points')
+
+ emitTemp(socket, 'd-1', 24.0, T0 + 30_000)
+ emitTemp(socket, 'd-1', 18.0, T0 + 60_000)
+
+ polyline = card.querySelector('.coldspot-sparkline-line')
+ const laterPoints = polyline!.getAttribute('points')
+ expect(laterPoints).not.toBe(firstPoints)
+ // three samples in means at least three coordinate pairs on the line.
+ expect(laterPoints!.trim().split(/\s+/).length).toBeGreaterThanOrEqual(3)
+ })
+
+ it('shows a pressure-anomaly callout and ripple treatment on a rapid swing', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({ type: 'devices', devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }] })
+ })
+ let t = T0
+ for (let i = 0; i < 4; i++) {
+ emitPressure(socket, 'd-1', 1013.0, t)
+ t += 30_000
+ }
+ expect(screen.queryByTestId('pressure-anomaly-callout')).toBeNull()
+
+ emitPressure(socket, 'd-1', 1013.0 + 6, t)
+
+ expect(screen.getByTestId('pressure-anomaly-callout')).toBeTruthy()
+ expect(screen.getByText(/PRESSURE ANOMALY/i)).toBeTruthy()
+ const card = screen.getByTestId('device-card-d-1')
+ expect(card.querySelector('.is-pressure-anomaly')).toBeTruthy()
+ })
+
+ it('only shows the composite disturbance gauge once both temperature and pressure have reported', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({ type: 'devices', devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }] })
+ })
+ emitTemp(socket, 'd-1', 21.0, T0)
+ expect(screen.queryByTestId('disturbance-gauge')).toBeNull()
+
+ emitPressure(socket, 'd-1', 1013.0, T0 + 5_000)
+ expect(screen.getByTestId('disturbance-gauge')).toBeTruthy()
+ })
+
+ it('spikes the composite index harder for a correlated cold-spot + pressure-anomaly event than either alone', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({
+ type: 'devices',
+ devices: [
+ { id: 'd-solo', name: 'solo-node', last_seen_at: null },
+ { id: 'd-both', name: 'both-node', last_seen_at: null },
+ ],
+ })
+ })
+
+ let t = T0
+ // warm up both devices identically on temp + pressure.
+ for (let i = 0; i < 4; i++) {
+ emitTemp(socket, 'd-solo', 21.0, t)
+ emitPressure(socket, 'd-solo', 1013.0, t)
+ emitTemp(socket, 'd-both', 21.0, t)
+ emitPressure(socket, 'd-both', 1013.0, t)
+ t += 30_000
+ }
+
+ // d-solo: only temperature deviates.
+ emitTemp(socket, 'd-solo', 15.0, t)
+ emitPressure(socket, 'd-solo', 1013.0, t)
+
+ // d-both: temperature AND pressure deviate together.
+ emitTemp(socket, 'd-both', 15.0, t)
+ emitPressure(socket, 'd-both', 1013.0 + 6, t)
+
+ const soloGauge = screen.getByTestId('device-card-d-solo').querySelector('.disturbance-gauge-index')
+ const bothGauge = screen.getByTestId('device-card-d-both').querySelector('.disturbance-gauge-index')
+ const soloIndex = Number(soloGauge!.textContent)
+ const bothIndex = Number(bothGauge!.textContent)
+ expect(bothIndex).toBeGreaterThan(soloIndex)
+ })
+
+ it('falls back to the generic reading row for a non-numeric temperature value, never throwing', async () => {
+ const socket = await renderPage()
+ act(() => {
+ socket.emit({ type: 'devices', devices: [{ id: 'd-1', name: 'attic-node', last_seen_at: null }] })
+ })
+ expect(() => {
+ act(() => {
+ socket.emit({
+ type: 'reading',
+ device_id: 'd-1',
+ sensor_type: 'temperature',
+ value: 'sensor offline',
+ unit: 'c',
+ metadata: {},
+ at: iso(T0),
+ })
+ })
+ }).not.toThrow()
+ expect(screen.getByText('sensor offline')).toBeTruthy()
+ expect(screen.queryByTestId('coldspot-panel')).toBeNull()
+ })
+})
+
describe('formatSensorValue', () => {
it('formats numbers, trimming float noise without hardcoding decimal places', () => {
expect(formatSensorValue(21.4)).toBe('21.4')
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx
index ef3ce69..24b0602 100644
--- a/frontend/src/pages/DevicesPage.tsx
+++ b/frontend/src/pages/DevicesPage.tsx
@@ -16,6 +16,15 @@
// hardware line, per the spec), so rendering is fully generic — an
// unrecognized sensor_type renders its raw value + unit with no
// special-casing, and can never crash the page.
+//
+// One exception carved out of that genericity: `temperature` and
+// `pressure` get the Cold Spot Detector / Atmospheric Disturbance Index
+// treatment (../lib/coldSpot.ts + ../components/ColdSpotPanel.tsx) —
+// folklore's two most iconic paranormal markers, a sudden cold spot and a
+// rapid barometric swing. This page owns the rolling-baseline state per
+// device (mirrors GhostLog.tsx owning tell history for computeEvilMeter)
+// and hands the *result* down to ColdSpotPanel, a pure presentational
+// component.
import { useCallback, useEffect, useState } from 'react'
import type { FormEvent } from 'react'
@@ -24,10 +33,95 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { DeviceFeedSocket } from '../lib/deviceFeed'
import type { DeviceFeedConnectionState, DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed'
+import {
+ applyPressureReading,
+ applyTemperatureReading,
+ initPressureBaseline,
+ initTemperatureBaseline,
+ pushSample,
+} from '../lib/coldSpot'
+import type { PressureBaselineState, TemperatureBaselineState } from '../lib/coldSpot'
+import { ColdSpotPanel } from '../components/ColdSpotPanel'
+import type { PressureReadout, TemperatureReadout } from '../components/ColdSpotPanel'
import './DevicesPage.css'
type Reading = { value: unknown; unit: string; at: string }
-type DeviceRow = DeviceSummary & { readings: Record }
+
+// Cold Spot Detector / Atmospheric Disturbance Index — rolling-baseline
+// state for this device's temperature/pressure readings (lib/coldSpot.ts),
+// plus the last computed readout for each and a bounded temperature
+// sample history for the sparkline. Threaded through DeviceRow the same
+// way `readings` is: plain immutable state replaced on each update, no
+// mutation.
+type AtmosphereState = {
+ tempBaseline: TemperatureBaselineState
+ pressureBaseline: PressureBaselineState
+ temp: TemperatureReadout | null
+ pressure: PressureReadout | null
+ tempSparkline: number[]
+}
+
+function initAtmosphere(): AtmosphereState {
+ return {
+ tempBaseline: initTemperatureBaseline(),
+ pressureBaseline: initPressureBaseline(),
+ temp: null,
+ pressure: null,
+ tempSparkline: [],
+ }
+}
+
+/**
+ * Folds one `reading` frame into a device's atmosphere state. A no-op for
+ * any sensor_type other than temperature/pressure, and for a
+ * temperature/pressure reading whose value isn't a finite number (a
+ * malformed reading falls back to the existing generic sensor-row
+ * rendering instead of silently vanishing — see the `hideFromGeneric`
+ * logic in the dashboard render below).
+ */
+function applyAtmosphereReading(
+ atmosphere: AtmosphereState,
+ frame: { sensor_type: string; value: unknown; unit: string; at: string },
+): AtmosphereState {
+ if (typeof frame.value !== 'number' || !Number.isFinite(frame.value)) return atmosphere
+ const atMs = parseAtMs(frame.at)
+
+ if (frame.sensor_type === 'temperature') {
+ const result = applyTemperatureReading(atmosphere.tempBaseline, frame.value, atMs)
+ return {
+ ...atmosphere,
+ tempBaseline: result.state,
+ temp: {
+ value: frame.value,
+ unit: frame.unit,
+ deviation: result.deviation,
+ isColdSpot: result.isColdSpot,
+ severity: result.severity,
+ },
+ tempSparkline: pushSample(atmosphere.tempSparkline, frame.value),
+ }
+ }
+
+ if (frame.sensor_type === 'pressure') {
+ const result = applyPressureReading(atmosphere.pressureBaseline, frame.value, atMs)
+ return {
+ ...atmosphere,
+ pressureBaseline: result.state,
+ pressure: {
+ value: frame.value,
+ unit: frame.unit,
+ deviation: result.deviation,
+ isPressureAnomaly: result.isPressureAnomaly,
+ severity: result.severity,
+ direction: result.direction,
+ },
+ }
+ }
+
+ return atmosphere
+}
+
+type DeviceRow = DeviceSummary & { readings: Record; atmosphere: AtmosphereState }
type PairState = 'idle' | 'pending' | 'error'
type PairResponse = {
@@ -72,6 +166,16 @@ function formatTimestamp(iso: string, locale: string): string {
}).format(date)
}
+/** Epoch ms for a reading's `at` timestamp, defensively — the rolling
+ * baseline needs a real clock value to weight its EMA by elapsed time
+ * (lib/coldSpot.ts), and hardware/network hiccups can in principle deliver
+ * a malformed timestamp. Falling back to "now" keeps the baseline moving
+ * forward sanely instead of propagating NaN into device state. */
+function parseAtMs(iso: string): number {
+ const ms = Date.parse(iso)
+ return Number.isFinite(ms) ? ms : Date.now()
+}
+
export function DevicesPage() {
const { user, checking } = useAuth()
const { t } = useTranslation()
@@ -110,7 +214,11 @@ function DevicesSession() {
const upsertPairedDevice = useCallback((summary: DeviceSummary) => {
setDevices((prev) => ({
...prev,
- [summary.id]: { ...summary, readings: prev[summary.id]?.readings ?? {} },
+ [summary.id]: {
+ ...summary,
+ readings: prev[summary.id]?.readings ?? {},
+ atmosphere: prev[summary.id]?.atmosphere ?? initAtmosphere(),
+ },
}))
setOrder((prev) => (prev.includes(summary.id) ? prev : [...prev, summary.id]))
}, [])
@@ -171,7 +279,11 @@ function DevicesSession() {
setDevices((prev) => {
const next: Record = {}
for (const d of frame.devices) {
- next[d.id] = { ...d, readings: prev[d.id]?.readings ?? {} }
+ next[d.id] = {
+ ...d,
+ readings: prev[d.id]?.readings ?? {},
+ atmosphere: prev[d.id]?.atmosphere ?? initAtmosphere(),
+ }
}
return next
})
@@ -186,6 +298,7 @@ function DevicesSession() {
name: frame.device_id,
last_seen_at: null,
readings: {},
+ atmosphere: initAtmosphere(),
}
return {
...prev,
@@ -196,6 +309,7 @@ function DevicesSession() {
...base.readings,
[frame.sensor_type]: { value: frame.value, unit: frame.unit, at: frame.at },
},
+ atmosphere: applyAtmosphereReading(base.atmosphere, frame),
},
}
})
@@ -305,6 +419,18 @@ function DevicesSession() {
const device = devices[id]
if (!device) return null
const sensorTypes = Object.keys(device.readings)
+ // temperature/pressure get the Cold Spot Detector treatment
+ // (ColdSpotPanel below) once they've reported a usable
+ // numeric value at least once; a malformed non-numeric
+ // reading for either falls back to the generic row instead
+ // of vanishing (see applyAtmosphereReading's no-op guard).
+ const genericSensorTypes = sensorTypes.filter((sensorType) => {
+ if (sensorType === 'temperature' && device.atmosphere.temp) return false
+ if (sensorType === 'pressure' && device.atmosphere.pressure) return false
+ return true
+ })
+ const hasAtmosphere = device.atmosphere.temp !== null || device.atmosphere.pressure !== null
+ const hasAnyReadout = hasAtmosphere || genericSensorTypes.length > 0
return (
@@ -316,25 +442,36 @@ function DevicesSession() {
: t('devices.dashboard.lastSeenNever', 'never')}
- {sensorTypes.length === 0 ? (
+ {!hasAnyReadout ? (
{t('devices.dashboard.noReadings', 'awaiting first reading…')}