/** * Per-signal anomaly detection against a learned baseline. * * The baseline is learned from a warmup period of clean running and then FROZEN. * That freeze is the important design choice: a continuously adapting baseline * quietly absorbs a slow ramp, so the exact failure mode this demo is built to * catch would never raise a z-score. Freezing means "different from how this * machine normally behaves", which is what an operator actually wants to know. */ export class BaselineTracker { /** * warmupSec - simulated seconds of clean data used to learn mean and spread * minStd - floor on the standard deviation, so a very quiet signal does not * produce enormous z-scores from rounding-level noise */ constructor({ warmupSec = 300, minStd = 1e-3 } = {}) { this.warmupSec = warmupSec; this.minStd = minStd; this.reset(); } reset() { this.n = 0; this.sum = 0; this.sumSq = 0; this.mean = 0; this.std = 0; this.ready = false; this.startT = null; } /** * Feed a sample. `clean` should be false when the line is in a known abnormal * state, so the baseline never learns from a fault it is supposed to detect. */ update(t, value, clean = true) { if (!Number.isFinite(value)) return; if (this.startT === null) this.startT = t; if (!this.ready) { if (clean) { this.n += 1; this.sum += value; this.sumSq += value * value; } if (t - this.startT >= this.warmupSec && this.n > 20) { this.mean = this.sum / this.n; const variance = Math.max(0, this.sumSq / this.n - this.mean * this.mean); this.std = Math.max(this.minStd, Math.sqrt(variance)); this.ready = true; } } } /** Signed z-score, or null while the baseline is still being learned. */ z(value) { if (!this.ready || !Number.isFinite(value)) return null; return (value - this.mean) / this.std; } } /** * A bank of baselines keyed by "STATION.signal". * * The exclusion list is DERIVED from the asset model rather than hardcoded here, * so when a signal is added to server/sim/stations.js the decision about whether * it can be anomaly-tested lives next to its definition. Two kinds are excluded: * * cumulative - monotonically accumulating values (tool wear, part counters). * Normal operation carries them far from any frozen baseline, so a * z-score reports ordinary accumulation as a fault. Tool wear * reaching 27% against a learned 18% baseline is not an anomaly, * it is a Tuesday. * volatile - values that legitimately swing with station state (belt speed * goes to zero on every micro-stop) or that are operator inputs * rather than measurements (a setpoint). * * Both remain fully covered by threshold alarms and trend projection, which are * the appropriate detectors for them. */ import { STATION_SPECS } from '../sim/stations.js'; const EXCLUDED = new Set( STATION_SPECS.flatMap((spec) => spec.signals .filter((g) => g.cumulative || g.volatile) .map((g) => `${spec.id}.${g.key}`), ), ); export class BaselineBank { constructor(opts = {}) { this.opts = opts; this.trackers = new Map(); } reset() { this.trackers.clear(); } key(stationId, signal) { return `${stationId}.${signal}`; } tracked(stationId, signal) { return !EXCLUDED.has(this.key(stationId, signal)); } get(stationId, signal) { const k = this.key(stationId, signal); let tr = this.trackers.get(k); if (!tr) { tr = new BaselineTracker(this.opts); this.trackers.set(k, tr); } return tr; } update(stationId, signal, t, value, clean) { if (!this.tracked(stationId, signal)) return; this.get(stationId, signal).update(t, value, clean); } z(stationId, signal, value) { if (!this.tracked(stationId, signal)) return null; return this.get(stationId, signal).z(value); } }