/** * The analytics layer: thresholds, anomalies, trend projections, alarm latching. * * Alarms are latched with hysteresis. Without it, a noisy signal sitting on a * threshold produces a flickering alarm list, which on a projector reads as a * broken product. A condition must hold for RAISE_AFTER seconds before it is * raised and clear for CLEAR_AFTER seconds before it is dropped. */ import { STATION_SPECS, STATE } from '../sim/stations.js'; import { TrendTracker, formatDuration } from './trend.js'; import { BaselineBank } from './anomaly.js'; const RAISE_AFTER = 3; const CLEAR_AFTER = 15; /** Sigma from the learned baseline before a signal is called anomalous. */ const ANOMALY_Z = 4.5; /** Only project a threshold crossing this far ahead, in simulated seconds. */ const PREDICTION_HORIZON = 7200; export const SEVERITY_RANK = { critical: 0, major: 1, warning: 2, predictive: 3, info: 4 }; /** Signals worth fitting a trend to: they drift, and they have a threshold. */ function trendableSignals() { const out = []; for (const spec of STATION_SPECS) { for (const g of spec.signals) { const hasHigh = g.warnHigh !== undefined || g.alarmHigh !== undefined; if (!hasHigh) continue; if (g.key === 'motorAmps' || g.key === 'burnerDuty') continue; // state-driven, not drift out.push({ stationId: spec.id, signal: g }); } } return out; } export class AnalyticsEngine { constructor() { this.trends = new Map(); this.baselines = new BaselineBank({ warmupSec: 240 }); this.candidates = new Map(); this.active = new Map(); this.trendable = trendableSignals(); } reset() { this.trends.clear(); this.baselines.reset(); this.candidates.clear(); this.active.clear(); } trend(stationId, key) { const k = `${stationId}.${key}`; let tr = this.trends.get(k); if (!tr) { tr = new TrendTracker(); this.trends.set(k, tr); } return tr; } /** * Fold one snapshot into the analytics state and return the analytics block. */ update(snap) { const t = snap.t; const stationById = {}; for (const st of snap.stations) stationById[st.id] = st; // A station in a known-abnormal state must not teach the baseline. const lineClean = snap.faults.length === 0; for (const spec of STATION_SPECS) { const st = stationById[spec.id]; if (!st || !st.online) continue; const stationClean = lineClean && st.state !== STATE.DOWN && st.state !== STATE.FAULT; for (const g of spec.signals) { const v = st.signals[g.key]; this.baselines.update(spec.id, g.key, t, v, stationClean); } } for (const { stationId, signal } of this.trendable) { const st = stationById[stationId]; if (!st || !st.online) continue; // Only fit while the station is actually producing, so stall dips do not // flatten or corrupt the slope. if (st.state !== STATE.RUNNING) continue; this.trend(stationId, signal.key).update(t, st.signals[signal.key]); } const found = []; this.collectStateAlarms(snap, found); this.collectThresholdAlarms(snap, stationById, found); this.collectAnomalyAlarms(snap, stationById, found); const predictions = this.collectPredictions(snap, stationById, found); const alarms = this.latch(t, found); return { alarms, predictions, trends: this.trendSummary(stationById), baselineReady: [...this.baselines.trackers.values()].some((b) => b.ready), }; } collectStateAlarms(snap, out) { for (const st of snap.stations) { if (!st.online) { out.push({ key: `offline:${st.id}`, kind: 'data-quality', severity: 'major', station: st.id, signal: null, message: `${st.id} is not reporting. Values shown are the last known reading.`, }); } if (st.state === STATE.FAULT) { const f = snap.faults.find((x) => x.station === st.id); out.push({ key: `fault:${st.id}`, kind: 'stoppage', severity: 'critical', station: st.id, signal: null, message: f ? `${st.id} stopped: ${f.label}.` : `${st.id} stopped on fault.`, }); } else if (st.state === STATE.DOWN) { out.push({ key: `down:${st.id}`, kind: 'stoppage', severity: 'major', station: st.id, signal: null, message: `${st.id} unplanned stop.`, }); } } } collectThresholdAlarms(snap, stationById, out) { for (const spec of STATION_SPECS) { const st = stationById[spec.id]; if (!st || !st.online) continue; // A stopped station drops many signals to zero by design. Alarming on that // duplicates the stoppage alarm and buries the real cause. const stopped = st.state === STATE.FAULT || st.state === STATE.DOWN || st.state === STATE.MICROSTOP; for (const g of spec.signals) { const v = st.signals[g.key]; if (!Number.isFinite(v)) continue; let severity = null, bound = null, dir = null; if (g.alarmHigh !== undefined && v >= g.alarmHigh) { severity = 'major'; bound = g.alarmHigh; dir = 'above'; } else if (g.warnHigh !== undefined && v >= g.warnHigh) { severity = 'warning'; bound = g.warnHigh; dir = 'above'; } else if (!stopped && g.alarmLow !== undefined && v <= g.alarmLow) { severity = 'major'; bound = g.alarmLow; dir = 'below'; } else if (!stopped && g.warnLow !== undefined && v <= g.warnLow) { severity = 'warning'; bound = g.warnLow; dir = 'below'; } if (!severity) continue; out.push({ key: `thresh:${spec.id}.${g.key}`, kind: 'threshold', severity, station: spec.id, signal: g.key, value: v, threshold: bound, message: `${g.label} ${v.toFixed(g.precision)} ${g.unit} is ${dir} the ${severity === 'major' ? 'alarm' : 'warning'} limit of ${bound} ${g.unit}.`, }); } } } collectAnomalyAlarms(snap, stationById, out) { const already = new Set(out.filter((a) => a.signal).map((a) => `${a.station}.${a.signal}`)); for (const spec of STATION_SPECS) { const st = stationById[spec.id]; if (!st || !st.online) continue; if (st.state !== STATE.RUNNING) continue; for (const g of spec.signals) { if (already.has(`${spec.id}.${g.key}`)) continue; const v = st.signals[g.key]; const z = this.baselines.z(spec.id, g.key, v); if (z === null || Math.abs(z) < ANOMALY_Z) continue; // Only alarm in the direction that is actually bad, inferred from which // thresholds the signal declares. Otherwise a tool change (wear drops // from 18% to 2%) or a genuine quality improvement raises an alarm for // being *better* than baseline, which trains operators to ignore alarms. const badHigh = g.warnHigh !== undefined || g.alarmHigh !== undefined; const badLow = g.warnLow !== undefined || g.alarmLow !== undefined; if (z > 0 && badLow && !badHigh) continue; if (z < 0 && badHigh && !badLow) continue; const tr = this.baselines.get(spec.id, g.key); out.push({ key: `anom:${spec.id}.${g.key}`, kind: 'anomaly', severity: 'warning', station: spec.id, signal: g.key, value: v, z, message: `${g.label} is ${Math.abs(z).toFixed(1)} sigma ${z > 0 ? 'above' : 'below'} its learned baseline of ${tr.mean.toFixed(g.precision)} ${g.unit}.`, }); } } } collectPredictions(snap, stationById, out) { const predictions = []; for (const { stationId, signal } of this.trendable) { const st = stationById[stationId]; if (!st || !st.online) continue; const limit = signal.alarmHigh ?? signal.warnHigh; if (limit === undefined) continue; const v = st.signals[signal.key]; if (v >= limit) continue; // already there, no projection needed const proj = this.trend(stationId, signal.key).timeToThreshold(limit); if (!proj || proj.seconds > PREDICTION_HORIZON) continue; const p = { station: stationId, signal: signal.key, label: signal.label, unit: signal.unit, current: v, threshold: limit, seconds: proj.seconds, eta: formatDuration(proj.seconds), slopePerMin: proj.fit.slope * 60, r2: proj.fit.r2, windowSec: proj.fit.spanSec, }; predictions.push(p); out.push({ key: `pred:${stationId}.${signal.key}`, kind: 'prediction', severity: 'predictive', station: stationId, signal: signal.key, value: v, threshold: limit, prediction: p, message: `${signal.label} trending up ${(proj.fit.slope * 60).toFixed(3)} ${signal.unit}/min. At this rate it reaches the ${limit} ${signal.unit} limit in about ${formatDuration(proj.seconds)} of run time.`, }); } return predictions; } /** Per-signal slope summary, used to give the copilot conclusions not raw floats. */ trendSummary(stationById) { const out = []; for (const { stationId, signal } of this.trendable) { const f = this.trend(stationId, signal.key).fit(); if (!f || f.r2 < 0.3) continue; const st = stationById[stationId]; if (!st) continue; out.push({ station: stationId, signal: signal.key, label: signal.label, unit: signal.unit, current: st.signals[signal.key], slopePerMin: f.slope * 60, r2: f.r2, windowSec: f.spanSec, }); } return out; } /** * Apply raise/clear hysteresis and return the active alarm list. */ latch(t, found) { const foundByKey = new Map(found.map((a) => [a.key, a])); for (const a of found) { const c = this.candidates.get(a.key); if (c) { c.lastSeen = t; c.payload = a; } else this.candidates.set(a.key, { firstSeen: t, lastSeen: t, payload: a }); } for (const [key, c] of this.candidates) { const stillPresent = foundByKey.has(key); const act = this.active.get(key); if (stillPresent && !act && t - c.firstSeen >= RAISE_AFTER) { this.active.set(key, { ...c.payload, raisedAt: t }); } else if (act) { if (stillPresent) { // Refresh the payload so values and messages stay live, keep raisedAt. this.active.set(key, { ...c.payload, raisedAt: act.raisedAt }); } else if (t - c.lastSeen >= CLEAR_AFTER) { this.active.delete(key); this.candidates.delete(key); } } else if (!stillPresent && t - c.lastSeen >= CLEAR_AFTER) { this.candidates.delete(key); } } return [...this.active.values()].sort( (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || b.raisedAt - a.raisedAt, ); } }