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:
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user