295 lines
11 KiB
JavaScript
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,
|
|
);
|
|
}
|
|
}
|