seance.tsx / JudgmentPanel.tsx: neither `ritual_complete` nor `judgment_result` carries an entity id, and both were applied unconditionally — so a response still in flight when the seeker summoned a fresh entity landed on whatever entity happened to be current when it arrived, leaking the *previous* entity's hidden traits into the new one's revealed-traits UI. Both frames are now gated on our still waiting for one (ritual.status === 'in_progress' / judgmentPending), which the 'entity' case clears the moment a new presence arrives, so a late answer for the old entity is dropped instead of misattributed. JudgmentPanel also had `pending` in component-local state that only cleared when judgmentResult became a *new* truthy object. If the entity changed while judgmentResult was already null, the reset was a no-op (null === null) and pending stayed stuck, permanently disabling all four verdict buttons. It now reads the shared judgmentPending flag, which the reducer resets. coldSpot.ts: severity was ungated by `warm` while isColdSpot/ isPressureAnomaly were correctly gated. ColdSpotPanel feeds severity straight into the composite disturbance gauge with no boolean gate of its own, so a freshly-paired device could show "disturbance rising" off its 2nd reading — exactly what the minSamples warm-up exists to prevent. PlanchetteBoard.tsx: the first GOODBYE deadline was a bare randomBetween(120,300) compared against `t`, which is seconds since performance.timeOrigin (page load), not since mount. Every later reschedule correctly offsets from `t`. On a tab open >5min before the board mounted (or any remount via navigation), t was already past the deadline and the planchette snapped to GOODBYE on the first frame. sdr.ts: close() and setFrequency() inside the sweep loop were not wrapped in withTimeout despite the file's own header claiming every stalling USB call is. A dongle going unresponsive mid-sweep or during teardown hung forever — the same silent-hang symptom withTimeout was added to eliminate. InventoryPanel.tsx: essence was decremented client-side using a possibly stale fallback price and never reconciled. The server already returns the real post-purchase balance in PurchaseOut; use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
// 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
|
|
// Gated by `warm` for the same reason isColdSpot is: pre-warm-up
|
|
// deviations are against a baseline that hasn't had a real chance to
|
|
// average out sensor noise, and severity feeds the composite disturbance
|
|
// gauge directly (ColdSpotPanel.tsx), which has no boolean gate of its
|
|
// own to catch an ungated value here.
|
|
const severity = warm ? clamp01(drop / (COLD_SPOT_DROP_C * 3)) : 0
|
|
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
|
|
// See applyTemperatureReading's comment: gated by `warm` so the composite
|
|
// disturbance gauge can't be driven by a pre-warm-up baseline swing.
|
|
const severity = warm ? clamp01(swing / (PRESSURE_SWING_HPA * 3)) : 0
|
|
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
|
|
}
|