Initial commit
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Trend extrapolation.
|
||||
*
|
||||
* Ordinary least squares over a decimated window of recent samples. Chosen over
|
||||
* anything fancier for one reason: when the customer asks "how does it predict
|
||||
* that?", the answer is one sentence - a straight-line fit over the last N
|
||||
* minutes, projected to the alarm threshold, reported only when the fit is good
|
||||
* enough to mean something.
|
||||
*
|
||||
* This is deliberately NOT presented as AI. It is a regression.
|
||||
*/
|
||||
|
||||
export class TrendTracker {
|
||||
/**
|
||||
* windowSec - how much history to fit over, in simulated seconds
|
||||
* sampleEverySec- decimation interval, keeps the fit cheap and less noise-bound
|
||||
* minR2 - below this the fit is reported as unreliable, no projection
|
||||
*/
|
||||
constructor({ windowSec = 1200, sampleEverySec = 10, minR2 = 0.55 } = {}) {
|
||||
this.windowSec = windowSec;
|
||||
this.sampleEverySec = sampleEverySec;
|
||||
this.minR2 = minR2;
|
||||
this.samples = [];
|
||||
this.lastSampleT = -Infinity;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.samples = [];
|
||||
this.lastSampleT = -Infinity;
|
||||
}
|
||||
|
||||
update(t, value) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (t - this.lastSampleT < this.sampleEverySec) return;
|
||||
this.lastSampleT = t;
|
||||
this.samples.push({ t, v: value });
|
||||
const cutoff = t - this.windowSec;
|
||||
while (this.samples.length && this.samples[0].t < cutoff) this.samples.shift();
|
||||
}
|
||||
|
||||
/** Least-squares fit. slope is in units per simulated second. */
|
||||
fit() {
|
||||
const n = this.samples.length;
|
||||
if (n < 6) return null;
|
||||
|
||||
let st = 0, sv = 0;
|
||||
for (const s of this.samples) { st += s.t; sv += s.v; }
|
||||
const mt = st / n, mv = sv / n;
|
||||
|
||||
let stt = 0, stv = 0, svv = 0;
|
||||
for (const s of this.samples) {
|
||||
const dt = s.t - mt, dv = s.v - mv;
|
||||
stt += dt * dt; stv += dt * dv; svv += dv * dv;
|
||||
}
|
||||
if (stt === 0) return null;
|
||||
|
||||
const slope = stv / stt;
|
||||
const r2 = svv === 0 ? 0 : (stv * stv) / (stt * svv);
|
||||
return {
|
||||
slope,
|
||||
intercept: mv - slope * mt,
|
||||
mean: mv,
|
||||
n,
|
||||
r2,
|
||||
spanSec: this.samples[n - 1].t - this.samples[0].t,
|
||||
latest: this.samples[n - 1].v,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulated seconds until the fitted line reaches `threshold`.
|
||||
*
|
||||
* Returns null when there is no usable projection: too little data, a fit too
|
||||
* poor to trust, movement in the wrong direction, or the threshold already
|
||||
* crossed. Returning null is the honest answer and the UI shows nothing.
|
||||
*/
|
||||
timeToThreshold(threshold) {
|
||||
const f = this.fit();
|
||||
if (!f) return null;
|
||||
if (f.r2 < this.minR2) return null;
|
||||
|
||||
const current = f.latest;
|
||||
const rising = threshold > current;
|
||||
if (rising && f.slope <= 1e-9) return null;
|
||||
if (!rising && f.slope >= -1e-9) return null;
|
||||
|
||||
const secs = (threshold - current) / f.slope;
|
||||
if (!Number.isFinite(secs) || secs <= 0) return null;
|
||||
return { seconds: secs, fit: f };
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable duration for a span of simulated seconds. */
|
||||
export function formatDuration(sec) {
|
||||
if (!Number.isFinite(sec)) return 'unknown';
|
||||
if (sec < 90) return `${Math.round(sec)} s`;
|
||||
if (sec < 5400) return `${(sec / 60).toFixed(1)} min`;
|
||||
if (sec < 172800) return `${(sec / 3600).toFixed(1)} h`;
|
||||
return `${(sec / 86400).toFixed(1)} d`;
|
||||
}
|
||||
Reference in New Issue
Block a user