Files
Digitaltwin/server/analytics/alarms.js
T

295 lines
11 KiB
JavaScript

/**
* 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,
);
}
}