From 917cd7f89ebc5645b397af1294acdf9e7591b19b Mon Sep 17 00:00:00 2001 From: Indiana Date: Fri, 24 Jul 2026 22:00:35 +0000 Subject: [PATCH] feat: Cold Spot Detector / Atmospheric Disturbance Index on Device Bay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/ColdSpotPanel.css | 251 +++++++++++++++++ frontend/src/components/ColdSpotPanel.tsx | 260 ++++++++++++++++++ frontend/src/i18n/en.json | 10 + frontend/src/i18n/es.json | 10 + frontend/src/lib/coldSpot.test.ts | 283 +++++++++++++++++++ frontend/src/lib/coldSpot.ts | 316 ++++++++++++++++++++++ frontend/src/pages/DevicesPage.test.tsx | 184 +++++++++++++ frontend/src/pages/DevicesPage.tsx | 173 ++++++++++-- 8 files changed, 1469 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/ColdSpotPanel.css create mode 100644 frontend/src/components/ColdSpotPanel.tsx create mode 100644 frontend/src/lib/coldSpot.test.ts create mode 100644 frontend/src/lib/coldSpot.ts diff --git a/frontend/src/components/ColdSpotPanel.css b/frontend/src/components/ColdSpotPanel.css new file mode 100644 index 0000000..cb721db --- /dev/null +++ b/frontend/src/components/ColdSpotPanel.css @@ -0,0 +1,251 @@ +/* Cold Spot Detector / Atmospheric Disturbance Index — lives inside a + Device Bay card (DevicesPage.css owns the surrounding .device-card / + .sensor-row chrome; this file only adds the frost/ripple/gauge layer on + top of it). Same dark/violet --qm-* palette, with an added ice-blue for + the cold-spot treatment — the one new hue in this feature, reserved + entirely for "something got colder than it should have". */ + +.coldspot-panel { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.coldspot-row-main { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.7rem; +} + +.coldspot-section-title { + margin: 0; + font-size: 0.62rem; + letter-spacing: 0.16em; + text-transform: uppercase; + text-align: center; + color: rgba(216, 230, 220, 0.55); +} + +/* ---- sparkline ---- */ + +.coldspot-sparkline { + width: 100%; + height: 20px; + display: block; + overflow: visible; +} + +.coldspot-sparkline-line { + fill: none; + stroke: rgba(124, 255, 178, 0.55); + stroke-width: 1.4; + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +.is-cold-spot .coldspot-sparkline-line { + stroke: #9fe0ff; + filter: drop-shadow(0 0 3px rgba(159, 224, 255, 0.7)); +} + +/* ---- cold spot: frosted-glass / crystalline treatment ---- */ + +.coldspot-temp-row { + border-radius: 3px; + padding: 0.4rem 0.5rem; + margin: -0.4rem -0.5rem 0; + transition: background 200ms ease, border-color 200ms ease, box-shadow 200ms ease; +} + +.coldspot-temp-row.is-cold-spot { + background: linear-gradient(155deg, rgba(159, 224, 255, 0.14), rgba(80, 160, 255, 0.03)); + border: 1px solid rgba(159, 224, 255, 0.4); + border-image: repeating-linear-gradient( + 118deg, + rgba(159, 224, 255, 0.65) 0px, + rgba(159, 224, 255, 0.65) 2px, + rgba(159, 224, 255, 0.05) 2px, + rgba(159, 224, 255, 0.05) 7px + ) + 1; + box-shadow: + inset 0 0 16px rgba(159, 224, 255, 0.18), + 0 0 12px rgba(159, 224, 255, 0.2); + backdrop-filter: blur(0.5px) brightness(1.03); + animation: coldspot-frost-pulse 3.2s ease-in-out infinite; +} + +.coldspot-temp-row.is-cold-spot .sensor-value { + color: #cdeeff; + text-shadow: 0 0 10px rgba(159, 224, 255, 0.75); +} + +.coldspot-frost-glyph { + color: #9fe0ff; + text-shadow: 0 0 6px rgba(159, 224, 255, 0.7); + margin-right: 0.3rem; +} + +@keyframes coldspot-frost-pulse { + 0%, 100% { box-shadow: inset 0 0 16px rgba(159, 224, 255, 0.18), 0 0 12px rgba(159, 224, 255, 0.2); } + 50% { box-shadow: inset 0 0 22px rgba(159, 224, 255, 0.28), 0 0 18px rgba(159, 224, 255, 0.32); } +} + +/* ---- pressure: expanding-ripple / distortion treatment ---- */ + +.coldspot-pressure-row { + position: relative; + border-radius: 3px; + padding: 0.4rem 0.5rem; + margin: -0.4rem -0.5rem 0; + overflow: visible; + transition: background 200ms ease, border-color 200ms ease; +} + +.coldspot-pressure-row.is-pressure-anomaly { + background: rgba(178, 107, 255, 0.07); + border: 1px solid rgba(178, 107, 255, 0.4); +} + +.coldspot-pressure-row.is-pressure-anomaly .sensor-value { + color: #d8bcff; + text-shadow: 0 0 10px rgba(178, 107, 255, 0.65); +} + +.coldspot-ripple-glyph { + position: relative; + display: inline-block; + color: #b26bff; + text-shadow: 0 0 6px rgba(178, 107, 255, 0.7); + margin-right: 0.3rem; +} + +.coldspot-pressure-row.is-pressure-anomaly::after { + content: ''; + position: absolute; + left: 12px; + top: 50%; + width: 8px; + height: 8px; + margin-top: -4px; + border-radius: 50%; + border: 1px solid rgba(178, 107, 255, 0.6); + pointer-events: none; + animation: coldspot-pressure-ripple 1.8s ease-out infinite; +} + +@keyframes coldspot-pressure-ripple { + 0% { transform: scale(1); opacity: 0.85; } + 100% { transform: scale(7); opacity: 0; } +} + +/* ---- shared callout ---- */ + +.coldspot-callout { + margin: 0; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 700; +} + +.coldspot-temp-row .coldspot-callout { + color: #9fe0ff; + text-shadow: 0 0 6px rgba(159, 224, 255, 0.5); +} + +.coldspot-pressure-row .coldspot-callout { + color: #b26bff; + text-shadow: 0 0 6px rgba(178, 107, 255, 0.5); +} + +/* ---- composite: Atmospheric Disturbance Index gauge ---- */ + +.disturbance-gauge { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + padding-top: 0.4rem; + border-top: 1px dotted rgba(124, 255, 178, 0.16); +} + +.disturbance-gauge-svg { + width: 100%; + max-width: 160px; + height: auto; + overflow: visible; +} + +.disturbance-gauge-track { + fill: none; + stroke: rgba(124, 255, 178, 0.12); + stroke-width: 10; + stroke-linecap: round; +} + +.disturbance-gauge-band { + filter: drop-shadow(0 0 5px rgba(124, 255, 178, 0.3)); + transition: d 220ms ease; +} + +/* stop colors track the same escalation bucket used for the label text */ +.disturbance-gauge-stop-start { + stop-color: #7cffb2; +} +.disturbance-gauge-stop-end { + stop-color: #7cffb2; +} + +.disturbance-gauge--faint .disturbance-gauge-stop-start { stop-color: #7cffb2; } +.disturbance-gauge--faint .disturbance-gauge-stop-end { stop-color: #9fe0ff; } + +.disturbance-gauge--rising .disturbance-gauge-stop-start { stop-color: #9fe0ff; } +.disturbance-gauge--rising .disturbance-gauge-stop-end { stop-color: #b26bff; } + +.disturbance-gauge--severe .disturbance-gauge-stop-start { stop-color: #b26bff; } +.disturbance-gauge--severe .disturbance-gauge-stop-end { stop-color: #ff3b5c; } + +.disturbance-gauge--converging .disturbance-gauge-stop-start { stop-color: #ff3b5c; } +.disturbance-gauge--converging .disturbance-gauge-stop-end { stop-color: #ffe066; } +.disturbance-gauge--converging .disturbance-gauge-band { + filter: drop-shadow(0 0 9px rgba(255, 59, 92, 0.55)); +} +.disturbance-gauge--converging { + animation: coldspot-converging-pulse 1.6s ease-in-out infinite; +} + +@keyframes coldspot-converging-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.82; } +} + +.disturbance-gauge-readout { + display: flex; + align-items: baseline; + gap: 0.4rem; + margin-top: -0.3rem; +} + +.disturbance-gauge-index { + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + color: #eafff3; +} + +.disturbance-gauge-label { + font-size: 0.66rem; + letter-spacing: 0.06em; + font-style: italic; + color: rgba(216, 230, 220, 0.7); +} + +@media (prefers-reduced-motion: reduce) { + .coldspot-temp-row.is-cold-spot, + .coldspot-pressure-row.is-pressure-anomaly::after, + .disturbance-gauge--converging { + animation: none; + } +} diff --git a/frontend/src/components/ColdSpotPanel.tsx b/frontend/src/components/ColdSpotPanel.tsx new file mode 100644 index 0000000..71e08f6 --- /dev/null +++ b/frontend/src/components/ColdSpotPanel.tsx @@ -0,0 +1,260 @@ +// Cold Spot Detector / Atmospheric Disturbance Index — the per-device +// visual layer over lib/coldSpot.ts's pure classifiers. DevicesPage.tsx +// owns and updates the rolling-baseline state as `reading` frames arrive +// (mirrors GhostLog.tsx owning tell history and handing the *result* of +// computeEvilMeter down to its gauge); this component is purely +// presentational — given a device's current temperature/pressure readouts +// (or null, if that sensor_type has never reported), it renders the +// frost/ripple treatments, callouts, sparkline and composite gauge, and +// renders nothing for a sensor that has never reported. A device with +// neither sensor renders nothing at all. + +import { useId } from 'react' +import { useTranslation } from 'react-i18next' +import { disturbanceIndex, disturbanceLabel } from '../lib/coldSpot' +import './ColdSpotPanel.css' + +export type TemperatureReadout = { + value: number + unit: string + deviation: number | null + isColdSpot: boolean + severity: number +} + +export type PressureReadout = { + value: number + unit: string + deviation: number | null + isPressureAnomaly: boolean + severity: number + direction: 'rise' | 'drop' +} + +type ColdSpotPanelProps = { + temperature: TemperatureReadout | null + pressure: PressureReadout | null + /** Recent temperature samples, oldest first — see lib/coldSpot.ts's + * pushSample/SPARKLINE_SAMPLE_CAP. */ + tempSparkline: readonly number[] +} + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)) +} + +export function ColdSpotPanel({ temperature, pressure, tempSparkline }: ColdSpotPanelProps) { + if (!temperature && !pressure) return null + + const bothReported = temperature !== null && pressure !== null + + return ( +
+ {temperature && } + {pressure && } + {bothReported && ( + + )} +
+ ) +} + +export default ColdSpotPanel + +// ---- temperature: frost treatment + sparkline ---- + +function Sparkline({ samples, width = 96, height = 22 }: { samples: readonly number[]; width?: number; height?: number }) { + if (samples.length === 0) return null + let points: string + if (samples.length === 1) { + points = `0,${height / 2} ${width},${height / 2}` + } else { + const min = Math.min(...samples) + const max = Math.max(...samples) + const range = max - min || 1 // avoid a degenerate 0-range when every sample is equal + const stepX = width / (samples.length - 1) + points = samples + .map((v, i) => { + const x = i * stepX + const norm = (v - min) / range + const y = height - norm * height + return `${x.toFixed(2)},${y.toFixed(2)}` + }) + .join(' ') + } + return ( + + ) +} + +function TemperatureReadoutRow({ + readout, + sparkline, +}: { + readout: TemperatureReadout + sparkline: readonly number[] +}) { + const { t } = useTranslation() + const { value, unit, deviation, isColdSpot } = readout + + return ( +
+
+ + {isColdSpot && ( + + )} + temperature + + + {Number(value.toFixed(3))} {unit} + +
+ + {isColdSpot && deviation !== null && ( +

+ {t('devices.dashboard.coldSpot.callout', { + deviation: Math.abs(deviation).toFixed(1), + unit, + defaultValue: 'COLD SPOT — {{deviation}} {{unit}} below baseline', + })} +

+ )} +
+ ) +} + +// ---- pressure: ripple treatment ---- + +function PressureReadoutRow({ readout }: { readout: PressureReadout }) { + const { t } = useTranslation() + const { value, unit, deviation, isPressureAnomaly } = readout + + return ( +
+
+ + {isPressureAnomaly && ( + + )} + pressure + + + {Number(value.toFixed(3))} {unit} + +
+ {isPressureAnomaly && deviation !== null && ( +

+ {t('devices.dashboard.pressureAnomaly.callout', { + magnitude: Math.abs(deviation).toFixed(1), + unit, + defaultValue: 'PRESSURE ANOMALY — {{magnitude}} {{unit}} swing, atmosphere shifting', + })} +

+ )} +
+ ) +} + +// ---- composite: Atmospheric Disturbance Index, a crescent arc gauge ---- +// (structurally the same polar-arc technique as GhostLog.tsx's evil-meter +// gauge — a family resemblance for the "hacker witch" visual language — +// but a single filled sweep rather than a belief band, since there's no +// uncertainty-spread concept here, just one 0..100 score.) + +const CX = 50 +const CY = 44 +const R = 32 +const BAND_THICKNESS = 10 + +function angleForValue(v: number): number { + return 180 - clamp01(v) * 180 +} + +function polar(radius: number, angleDeg: number): { x: number; y: number } { + const rad = (angleDeg * Math.PI) / 180 + return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) } +} + +function DisturbanceGauge({ index, correlated }: { index: number; correlated: boolean }) { + const { t } = useTranslation() + const gaugeId = useId() + const label = disturbanceLabel(index, correlated) + const value = clamp01(index / 100) + + const outerR = R + BAND_THICKNESS / 2 + const innerR = R - BAND_THICKNESS / 2 + const startAngle = 180 + const endAngle = angleForValue(value) + + const outerStart = polar(outerR, startAngle) + const outerEnd = polar(outerR, endAngle) + const innerEnd = polar(innerR, endAngle) + const innerStart = polar(innerR, startAngle) + + const bandPath = [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerR} ${outerR} 0 0 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${innerR} ${innerR} 0 0 0 ${innerStart.x} ${innerStart.y}`, + 'Z', + ].join(' ') + + const trackStart = polar(R, 180) + const trackEnd = polar(R, 0) + const trackPath = `M ${trackStart.x} ${trackStart.y} A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}` + + const bucketClass = correlated && index >= 40 ? 'converging' : index >= 70 ? 'severe' : index >= 40 ? 'rising' : index >= 15 ? 'faint' : 'calm' + + return ( +
+

+ {t('devices.dashboard.disturbance.title', { + defaultValue: 'ATMOSPHERIC DISTURBANCE', + })} +

+ + + + + + + + + + +
+ {index} + {label} +
+
+ ) +} 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…')}

) : ( -
- {sensorTypes.map((sensorType) => { - const r = device.readings[sensorType] - return ( -
- {sensorType} - - {formatSensorValue(r.value)}{' '} - {r.unit} - -
- ) - })} -
+ <> + {hasAtmosphere && ( + + )} + {genericSensorTypes.length > 0 && ( +
+ {genericSensorTypes.map((sensorType) => { + const r = device.readings[sensorType] + return ( +
+ {sensorType} + + {formatSensorValue(r.value)}{' '} + {r.unit} + +
+ ) + })} +
+ )} + )}
)