Merge Cold Spot Detector: atmospheric anomaly visualization for DevicesPage
This commit is contained in:
251
frontend/src/components/ColdSpotPanel.css
Normal file
251
frontend/src/components/ColdSpotPanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
260
frontend/src/components/ColdSpotPanel.tsx
Normal file
260
frontend/src/components/ColdSpotPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
283
frontend/src/lib/coldSpot.test.ts
Normal file
283
frontend/src/lib/coldSpot.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
316
frontend/src/lib/coldSpot.ts
Normal file
316
frontend/src/lib/coldSpot.ts
Normal 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
|
||||
}
|
||||
@@ -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,13 +442,22 @@ 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>
|
||||
) : (
|
||||
<>
|
||||
{hasAtmosphere && (
|
||||
<ColdSpotPanel
|
||||
temperature={device.atmosphere.temp}
|
||||
pressure={device.atmosphere.pressure}
|
||||
tempSparkline={device.atmosphere.tempSparkline}
|
||||
/>
|
||||
)}
|
||||
{genericSensorTypes.length > 0 && (
|
||||
<div className="sensor-grid">
|
||||
{sensorTypes.map((sensorType) => {
|
||||
{genericSensorTypes.map((sensorType) => {
|
||||
const r = device.readings[sensorType]
|
||||
return (
|
||||
<div className="sensor-row" key={sensorType}>
|
||||
@@ -336,6 +471,8 @@ function DevicesSession() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user