Files
Digitaltwin/server/analytics/anomaly.js
T

130 lines
3.9 KiB
JavaScript

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