feat: Cold Spot Detector / Atmospheric Disturbance Index on Device Bay

Real-time anomaly visualization for temperature/pressure readings on the
device-feed dashboard, folklore's two most iconic paranormal markers:
sudden cold spots and rapid barometric swings.

- lib/coldSpot.ts: pure, directly-testable rolling-baseline tracker
  (time-aware EMA, since hardware doesn't report on a fixed schedule),
  cold-spot and pressure-anomaly classifiers, and a composite
  Atmospheric Disturbance Index that rewards correlated anomalies
  (a lone signal caps at 50/100; only both deviating together can
  reach 100) — modeled on evilMeter.ts's threaded-state pattern.
- components/ColdSpotPanel.tsx/.css: frost treatment + sparkline for
  temperature, ripple treatment for pressure, and a crescent-arc
  composite gauge (GhostLog's evil-meter gauge as the visual family
  reference) that only appears once a device has reported both sensors.
- DevicesPage.tsx: owns per-device baseline state, feeds it from
  `reading` frames, falls back to the existing generic row for any
  non-numeric temperature/pressure value.

26 new coldSpot.test.ts cases (warm-up, genuine vs. fluctuation,
correlated-vs-solo index, gappy/out-of-order data) and 7 new
DevicesPage integration tests. Full suite: 302/302 passing, tsc clean,
i18n coverage clean (en/es).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-24 22:00:35 +00:00
parent 7ebedf363b
commit 917cd7f89e
8 changed files with 1469 additions and 18 deletions

View File

@@ -0,0 +1,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;
}
}

View File

@@ -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 (
<div className="coldspot-panel" data-testid="coldspot-panel">
{temperature && <TemperatureReadoutRow readout={temperature} sparkline={tempSparkline} />}
{pressure && <PressureReadoutRow readout={pressure} />}
{bothReported && (
<DisturbanceGauge
index={disturbanceIndex(temperature!.severity, pressure!.severity)}
correlated={temperature!.isColdSpot && pressure!.isPressureAnomaly}
/>
)}
</div>
)
}
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 (
<svg
className="coldspot-sparkline"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline points={points} className="coldspot-sparkline-line" />
</svg>
)
}
function TemperatureReadoutRow({
readout,
sparkline,
}: {
readout: TemperatureReadout
sparkline: readonly number[]
}) {
const { t } = useTranslation()
const { value, unit, deviation, isColdSpot } = readout
return (
<div className={`sensor-row coldspot-temp-row${isColdSpot ? ' is-cold-spot' : ''}`}>
<div className="coldspot-row-main">
<span className="sensor-type">
{isColdSpot && (
<span className="coldspot-frost-glyph" aria-hidden="true">
❄
</span>
)}
temperature
</span>
<span className="sensor-value flicker-soft">
{Number(value.toFixed(3))} <span className="sensor-unit">{unit}</span>
</span>
</div>
<Sparkline samples={sparkline} />
{isColdSpot && deviation !== null && (
<p className="coldspot-callout" data-testid="cold-spot-callout" role="status">
{t('devices.dashboard.coldSpot.callout', {
deviation: Math.abs(deviation).toFixed(1),
unit,
defaultValue: 'COLD SPOT — {{deviation}} {{unit}} below baseline',
})}
</p>
)}
</div>
)
}
// ---- pressure: ripple treatment ----
function PressureReadoutRow({ readout }: { readout: PressureReadout }) {
const { t } = useTranslation()
const { value, unit, deviation, isPressureAnomaly } = readout
return (
<div className={`sensor-row coldspot-pressure-row${isPressureAnomaly ? ' is-pressure-anomaly' : ''}`}>
<div className="coldspot-row-main">
<span className="sensor-type">
{isPressureAnomaly && (
<span className="coldspot-ripple-glyph" aria-hidden="true">
◎
</span>
)}
pressure
</span>
<span className="sensor-value flicker-soft">
{Number(value.toFixed(3))} <span className="sensor-unit">{unit}</span>
</span>
</div>
{isPressureAnomaly && deviation !== null && (
<p className="coldspot-callout" data-testid="pressure-anomaly-callout" role="status">
{t('devices.dashboard.pressureAnomaly.callout', {
magnitude: Math.abs(deviation).toFixed(1),
unit,
defaultValue: 'PRESSURE ANOMALY — {{magnitude}} {{unit}} swing, atmosphere shifting',
})}
</p>
)}
</div>
)
}
// ---- 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 (
<div
className={`disturbance-gauge disturbance-gauge--${bucketClass}`}
data-testid="disturbance-gauge"
>
<p className="coldspot-section-title">
{t('devices.dashboard.disturbance.title', {
defaultValue: 'ATMOSPHERIC DISTURBANCE',
})}
</p>
<svg
viewBox="0 0 100 50"
className="disturbance-gauge-svg"
role="meter"
aria-label={t('devices.dashboard.disturbance.ariaLabel', {
defaultValue: 'atmospheric disturbance index',
})}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={index}
aria-valuetext={label}
>
<defs>
<linearGradient id={gaugeId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" className="disturbance-gauge-stop-start" />
<stop offset="100%" className="disturbance-gauge-stop-end" />
</linearGradient>
</defs>
<path d={trackPath} className="disturbance-gauge-track" />
<path d={bandPath} fill={`url(#${gaugeId})`} className="disturbance-gauge-band" />
</svg>
<div className="disturbance-gauge-readout">
<span className="disturbance-gauge-index">{index}</span>
<span className="disturbance-gauge-label">{label}</span>
</div>
</div>
)
}

View File

@@ -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"
}
}
},

View File

@@ -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"
}
}
},

View File

@@ -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])
})
})

View File

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

View File

@@ -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')

View File

@@ -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<string, Reading> }
// 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<string, Reading>; 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<string, DeviceRow> = {}
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 (
<article className="device-card" key={id} data-testid={`device-card-${id}`}>
<header className="device-card-head">
@@ -316,25 +442,36 @@ function DevicesSession() {
: t('devices.dashboard.lastSeenNever', 'never')}
</p>
</header>
{sensorTypes.length === 0 ? (
{!hasAnyReadout ? (
<p className="device-noreadings">
{t('devices.dashboard.noReadings', 'awaiting first reading…')}
</p>
) : (
<div className="sensor-grid">
{sensorTypes.map((sensorType) => {
const r = device.readings[sensorType]
return (
<div className="sensor-row" key={sensorType}>
<span className="sensor-type">{sensorType}</span>
<span className="sensor-value flicker-soft">
{formatSensorValue(r.value)}{' '}
<span className="sensor-unit">{r.unit}</span>
</span>
</div>
)
})}
</div>
<>
{hasAtmosphere && (
<ColdSpotPanel
temperature={device.atmosphere.temp}
pressure={device.atmosphere.pressure}
tempSparkline={device.atmosphere.tempSparkline}
/>
)}
{genericSensorTypes.length > 0 && (
<div className="sensor-grid">
{genericSensorTypes.map((sensorType) => {
const r = device.readings[sensorType]
return (
<div className="sensor-row" key={sensorType}>
<span className="sensor-type">{sensorType}</span>
<span className="sensor-value flicker-soft">
{formatSensorValue(r.value)}{' '}
<span className="sensor-unit">{r.unit}</span>
</span>
</div>
)
})}
</div>
)}
</>
)}
</article>
)