Initial commit with Dockerfile and demo code
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Headless simulator verification. No UI, no dependencies.
|
||||
*
|
||||
* A simulator that lies is worse than no demo, so this asserts the behaviour the
|
||||
* demo actually claims: signals stay physical, faults move OEE the right way,
|
||||
* and a packer stop propagates upstream as blocking.
|
||||
*
|
||||
* node scripts/simcheck.mjs
|
||||
*/
|
||||
|
||||
import { ProductionLine } from '../server/sim/line.js';
|
||||
import { STATION_SPECS } from '../server/sim/stations.js';
|
||||
import { AnalyticsEngine } from '../server/analytics/alarms.js';
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
const mark = ok ? 'PASS' : 'FAIL';
|
||||
if (!ok) failures++;
|
||||
console.log(` [${mark}] ${name}${detail ? ' ' + detail : ''}`);
|
||||
}
|
||||
|
||||
function run(line, simSeconds) {
|
||||
const tick = 0.5;
|
||||
for (let i = 0; i < simSeconds / tick; i++) line.step(tick);
|
||||
return line.snapshot();
|
||||
}
|
||||
|
||||
/** Every signal must stay inside its declared physical range. */
|
||||
function checkRanges(snap, label) {
|
||||
let worst = null;
|
||||
for (const st of snap.stations) {
|
||||
const spec = STATION_SPECS.find((s) => s.id === st.id);
|
||||
for (const g of spec.signals) {
|
||||
const v = st.signals[g.key];
|
||||
if (!Number.isFinite(v)) { worst = `${st.id}.${g.key} = ${v}`; break; }
|
||||
// Allow a hair of overshoot on lag+noise, but nothing structural.
|
||||
const slack = (g.max - g.min) * 0.02;
|
||||
if (v < g.min - slack || v > g.max + slack) {
|
||||
worst = `${st.id}.${g.key} = ${v.toFixed(2)} outside [${g.min}, ${g.max}]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (worst) break;
|
||||
}
|
||||
check(`${label}: all signals in physical range`, !worst, worst || '');
|
||||
}
|
||||
|
||||
console.log('\n=== 1. Baseline: 15 simulated minutes at nominal ===');
|
||||
{
|
||||
const line = new ProductionLine();
|
||||
const snap = run(line, 900);
|
||||
checkRanges(snap, 'baseline');
|
||||
|
||||
const k = snap.kpi;
|
||||
console.log(` OEE ${(k.oee * 100).toFixed(1)}% A ${(k.availability * 100).toFixed(1)}% P ${(k.performance * 100).toFixed(1)}% Q ${(k.quality * 100).toFixed(1)}%`);
|
||||
console.log(` throughput ${k.throughputPerHour.toFixed(0)}/h scrap ${(k.scrapRate * 100).toFixed(2)}% energy ${k.energyKw.toFixed(1)} kW`);
|
||||
console.log(` buffers [${snap.buffers.join(', ')}] states: ${snap.stations.map((s) => s.id + '=' + s.state).join(' ')}`);
|
||||
|
||||
// World-class OEE is ~85%; a typical plant is 60-75%. Anything near 100% reads
|
||||
// as a fabricated demo, which is the one thing this number must not do.
|
||||
check('baseline OEE is credible (65-93%)', k.oee > 0.65 && k.oee < 0.93, `got ${(k.oee * 100).toFixed(1)}%`);
|
||||
check('performance never exceeds 100%', k.performance <= 1, `got ${(k.performance * 100).toFixed(1)}%`);
|
||||
check('availability reflects real stoppages (<100%)', k.availability <= 1);
|
||||
check('baseline scrap is low (<5%)', k.scrapRate < 0.05, `got ${(k.scrapRate * 100).toFixed(2)}%`);
|
||||
check('line is producing', snap.totals.good > 100, `${snap.totals.good} good parts`);
|
||||
check('oven holds setpoint within 2 C', Math.abs(snap.stations[2].signals.tempDeviation) < 2,
|
||||
`dev ${snap.stations[2].signals.tempDeviation.toFixed(2)} C`);
|
||||
check('no station is stuck in an unknown state',
|
||||
snap.stations.every((s) => ['running', 'starved', 'blocked', 'microstop', 'down'].includes(s.state)));
|
||||
}
|
||||
|
||||
console.log('\n=== 2. F1 bearing degradation: vibration ramp and OEE impact (A/B) ===');
|
||||
{
|
||||
// Controlled A/B: two lines from the same seed, identical except that B gets
|
||||
// the fault at t=300. Comparing a before/after snapshot on one line is not
|
||||
// valid here, because the rolling KPI window is still filling early in the run.
|
||||
const control = new ProductionLine(4242);
|
||||
const faulted = new ProductionLine(4242);
|
||||
|
||||
run(control, 300);
|
||||
run(faulted, 300);
|
||||
faulted.injectFault('bearing-degradation');
|
||||
|
||||
const marks = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
run(control, 300);
|
||||
// Track PEAK vibration across the interval, not the instantaneous value at
|
||||
// the sample instant. A stalled spindle legitimately vibrates less, so an
|
||||
// instantaneous sample taken during a micro-stop reads low and makes a real
|
||||
// upward trend look non-monotonic. Real condition monitoring reports peak or
|
||||
// high-percentile RMS over a window for exactly this reason.
|
||||
let peakVib = 0;
|
||||
for (let k = 0; k < 600; k++) {
|
||||
faulted.step(0.5);
|
||||
peakVib = Math.max(peakVib, faulted.stationById['CNC-02'].signals.vibration);
|
||||
}
|
||||
const snap = faulted.snapshot();
|
||||
marks.push({
|
||||
t: (i + 1) * 300,
|
||||
peakVib,
|
||||
wear: snap.stations[1].signals.toolWear,
|
||||
rej: snap.stations[3].signals.rejectRate,
|
||||
oee: snap.kpi.oee,
|
||||
ctlOee: control.snapshot().kpi.oee,
|
||||
});
|
||||
}
|
||||
for (const m of marks) {
|
||||
console.log(` t+${String(m.t).padStart(4)}s peak vib ${m.peakVib.toFixed(2)} mm/s wear ${m.wear.toFixed(1)}% reject ${m.rej.toFixed(2)}% OEE ${(m.oee * 100).toFixed(1)}% (control ${(m.ctlOee * 100).toFixed(1)}%)`);
|
||||
}
|
||||
|
||||
const finalF = faulted.snapshot();
|
||||
const finalC = control.snapshot();
|
||||
checkRanges(finalF, 'bearing fault');
|
||||
|
||||
// The fault profile saturates by design (a spalled bearing does not ramp
|
||||
// forever), so only assert monotonicity below the alarm threshold.
|
||||
const preSat = marks.filter((m) => m.peakVib < 4.5);
|
||||
check('peak vibration rises monotonically until saturation',
|
||||
preSat.length >= 2 && preSat.every((m, i) => i === 0 || m.peakVib > preSat[i - 1].peakVib),
|
||||
`${preSat.length} pre-saturation marks`);
|
||||
check('vibration ends far above baseline',
|
||||
marks[marks.length - 1].peakVib > marks[0].peakVib + 2,
|
||||
`${marks[0].peakVib.toFixed(2)} -> ${marks[marks.length - 1].peakVib.toFixed(2)} mm/s`);
|
||||
check('vibration crosses the 3.5 warn band', marks.some((m) => m.peakVib >= 3.5),
|
||||
`peak ${marks[marks.length - 1].peakVib.toFixed(2)}`);
|
||||
check('reject rate rises as a consequence',
|
||||
marks[marks.length - 1].rej > marks[0].rej + 0.5,
|
||||
`${marks[0].rej.toFixed(2)}% -> ${marks[marks.length - 1].rej.toFixed(2)}%`);
|
||||
check('OEE is materially worse than the control line',
|
||||
finalF.kpi.oee < finalC.kpi.oee - 0.05,
|
||||
`faulted ${(finalF.kpi.oee * 100).toFixed(1)}% vs control ${(finalC.kpi.oee * 100).toFixed(1)}%`);
|
||||
// Cumulative counts, not the rolling window: a ~250-part window at a few
|
||||
// percent reject is too small a sample to compare reliably.
|
||||
const scrapF = finalF.totals.rejected / finalF.totals.produced;
|
||||
const scrapC = finalC.totals.rejected / finalC.totals.produced;
|
||||
check('cumulative scrap is higher than the control line', scrapF > scrapC,
|
||||
`faulted ${(scrapF * 100).toFixed(2)}% vs control ${(scrapC * 100).toFixed(2)}%`);
|
||||
check('performance is worse than the control line (spindle chatter stalls the cut)',
|
||||
finalF.kpi.performance < finalC.kpi.performance,
|
||||
`faulted ${(finalF.kpi.performance * 100).toFixed(1)}% vs control ${(finalC.kpi.performance * 100).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
console.log('\n=== 3. F2 oven burner fault: cure runs cold, quality drops ===');
|
||||
{
|
||||
const line = new ProductionLine();
|
||||
run(line, 300);
|
||||
const before = line.snapshot();
|
||||
line.injectFault('oven-burner');
|
||||
const after = run(line, 900);
|
||||
checkRanges(after, 'oven fault');
|
||||
|
||||
const ovn = after.stations.find((s) => s.id === 'OVN-03');
|
||||
console.log(` zone2 ${ovn.signals.zone2Temp.toFixed(1)} C (setpoint ${ovn.signals.setpoint}) dev ${ovn.signals.tempDeviation.toFixed(1)} C duty ${ovn.signals.burnerDuty.toFixed(1)}%`);
|
||||
console.log(` reject ${after.stations[3].signals.rejectRate.toFixed(2)}% quality ${(after.kpi.quality * 100).toFixed(1)}% OEE ${(after.kpi.oee * 100).toFixed(1)}%`);
|
||||
|
||||
check('zone 2 sags below setpoint', ovn.signals.tempDeviation < -8, `dev ${ovn.signals.tempDeviation.toFixed(1)} C`);
|
||||
check('burner duty saturates trying to compensate', ovn.signals.burnerDuty > 95, `${ovn.signals.burnerDuty.toFixed(1)}%`);
|
||||
check('reject rate climbs from the cold cure', after.stations[3].signals.rejectRate > 4,
|
||||
`${after.stations[3].signals.rejectRate.toFixed(2)}%`);
|
||||
check('quality falls vs baseline', after.kpi.quality < before.kpi.quality - 0.01,
|
||||
`${(before.kpi.quality * 100).toFixed(1)}% -> ${(after.kpi.quality * 100).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
console.log('\n=== 4. F3 packer jam: blocking propagates upstream ===');
|
||||
{
|
||||
const line = new ProductionLine();
|
||||
run(line, 300);
|
||||
const before = line.snapshot();
|
||||
line.injectFault('packer-jam');
|
||||
|
||||
const seen = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const snap = run(line, 30);
|
||||
seen.push({ t: snap.t, states: snap.stations.map((s) => s.state), buffers: [...snap.buffers] });
|
||||
}
|
||||
for (const s of seen) {
|
||||
console.log(` t=${s.t.toFixed(0)}s buffers [${s.buffers.join(',')}] ${s.states.join(' ')}`);
|
||||
}
|
||||
const final = line.snapshot();
|
||||
checkRanges(final, 'packer jam');
|
||||
|
||||
check('packer is in fault', final.stations[4].state === 'fault');
|
||||
check('downstream buffer fills to capacity', final.buffers[3] >= final.bufferCapacity,
|
||||
`buffer[3] = ${final.buffers[3]}/${final.bufferCapacity}`);
|
||||
check('blocking reached the inspection station',
|
||||
seen.some((s) => s.states[3] === 'blocked'));
|
||||
check('blocking propagated all the way to the conveyor',
|
||||
final.stations[0].state === 'blocked',
|
||||
`CONV-01 = ${final.stations[0].state}`);
|
||||
check('all buffers backed up', final.buffers.every((b) => b >= final.bufferCapacity),
|
||||
`[${final.buffers.join(',')}]`);
|
||||
// 300 s of uptime then 180 s of jam inside a 600 s window is ~62% by
|
||||
// definition, so assert a meaningful drop rather than a collapse.
|
||||
check('availability drops sharply', final.kpi.availability < before.kpi.availability - 0.2,
|
||||
`${(before.kpi.availability * 100).toFixed(0)}% -> ${(final.kpi.availability * 100).toFixed(0)}%`);
|
||||
check('output rate falls to zero', final.stations[4].signals.unitsPerMin < 1,
|
||||
`${final.stations[4].signals.unitsPerMin.toFixed(2)} u/min`);
|
||||
}
|
||||
|
||||
console.log('\n=== 5. F4 sensor dropout: values go stale, not zero ===');
|
||||
{
|
||||
const line = new ProductionLine();
|
||||
run(line, 300);
|
||||
const before = line.snapshot().stations[3].signals.rejectRate;
|
||||
line.injectFault('sensor-dropout');
|
||||
const after = run(line, 240).stations[3];
|
||||
|
||||
console.log(` reject rate before ${before.toFixed(3)}% after ${after.signals.rejectRate.toFixed(3)}% online=${after.online}`);
|
||||
check('station reports offline', after.online === false);
|
||||
check('value held stale rather than zeroed', Math.abs(after.signals.rejectRate - before) < 1e-9);
|
||||
}
|
||||
|
||||
console.log('\n=== 6. What-if: oven setpoint change produces a lagged response ===');
|
||||
{
|
||||
const line = new ProductionLine();
|
||||
run(line, 300);
|
||||
const t0 = line.snapshot().stations[2].signals.zone2Temp;
|
||||
line.setSetpoint(330);
|
||||
const t1 = run(line, 15).stations[2].signals.zone2Temp;
|
||||
const t2 = run(line, 285).stations[2].signals.zone2Temp;
|
||||
|
||||
console.log(` zone2: ${t0.toFixed(1)} C -> +15s ${t1.toFixed(1)} C -> +300s ${t2.toFixed(1)} C (setpoint 330)`);
|
||||
check('response is lagged, not instant', t1 < t0 + 20, `+15s only reached ${t1.toFixed(1)} C`);
|
||||
check('eventually settles near the new setpoint', Math.abs(t2 - 330) < 3, `${t2.toFixed(1)} C`);
|
||||
}
|
||||
|
||||
console.log('\n=== 7. Determinism: same seed reproduces the same run ===');
|
||||
{
|
||||
const a = new ProductionLine(1234);
|
||||
const b = new ProductionLine(1234);
|
||||
a.injectFault('bearing-degradation');
|
||||
b.injectFault('bearing-degradation');
|
||||
const sa = run(a, 600);
|
||||
const sb = run(b, 600);
|
||||
check('identical vibration trace',
|
||||
sa.stations[1].signals.vibration === sb.stations[1].signals.vibration,
|
||||
`${sa.stations[1].signals.vibration.toFixed(6)} vs ${sb.stations[1].signals.vibration.toFixed(6)}`);
|
||||
check('identical part counts', sa.totals.good === sb.totals.good, `${sa.totals.good} vs ${sb.totals.good}`);
|
||||
}
|
||||
|
||||
console.log('\n=== 8. Analytics: baseline, prediction lead time, alarm latching ===');
|
||||
{
|
||||
const line = new ProductionLine(9001);
|
||||
const engine = new AnalyticsEngine();
|
||||
let out = null;
|
||||
|
||||
const feed = (seconds) => {
|
||||
for (let i = 0; i < seconds / 0.5; i++) {
|
||||
line.step(0.5);
|
||||
out = engine.update(line.snapshot());
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
feed(400);
|
||||
check('baseline learns from clean running', out.baselineReady);
|
||||
check('no alarms on a healthy line', out.alarms.length === 0,
|
||||
out.alarms.map((a) => a.key).join(', ') || 'clean');
|
||||
|
||||
line.injectFault('bearing-degradation');
|
||||
|
||||
// Walk forward and record when the prediction appears versus when the signal
|
||||
// actually crosses the alarm limit. The gap between them is the lead time,
|
||||
// which is the entire value proposition of the trend layer.
|
||||
let firstPredictionT = null, crossingT = null, firstAnomalyT = null;
|
||||
const alarmLimit = 4.5;
|
||||
for (let i = 0; i < 2400; i++) {
|
||||
line.step(0.5);
|
||||
out = engine.update(line.snapshot());
|
||||
const vib = line.stationById['CNC-02'].signals.vibration;
|
||||
const pred = out.predictions.find((p) => p.station === 'CNC-02' && p.signal === 'vibration');
|
||||
if (pred && firstPredictionT === null) firstPredictionT = line.simTime;
|
||||
if (firstAnomalyT === null && out.alarms.some((a) => a.key === 'anom:CNC-02.vibration')) {
|
||||
firstAnomalyT = line.simTime;
|
||||
}
|
||||
if (vib >= alarmLimit && crossingT === null) crossingT = line.simTime;
|
||||
if (crossingT !== null && firstPredictionT !== null) break;
|
||||
}
|
||||
|
||||
console.log(` anomaly raised at t=${firstAnomalyT === null ? 'never' : firstAnomalyT.toFixed(0) + 's'}`);
|
||||
console.log(` prediction first appeared at t=${firstPredictionT === null ? 'never' : firstPredictionT.toFixed(0) + 's'}`);
|
||||
console.log(` vibration crossed ${alarmLimit} mm/s at t=${crossingT === null ? 'never' : crossingT.toFixed(0) + 's'}`);
|
||||
|
||||
check('a prediction was produced', firstPredictionT !== null);
|
||||
check('the anomaly was flagged from the learned baseline', firstAnomalyT !== null);
|
||||
check('prediction arrives BEFORE the alarm threshold is crossed',
|
||||
firstPredictionT !== null && crossingT !== null && firstPredictionT < crossingT,
|
||||
firstPredictionT !== null && crossingT !== null
|
||||
? `lead time ${(crossingT - firstPredictionT).toFixed(0)} s of simulated run time` : '');
|
||||
|
||||
const pred = out.predictions.find((p) => p.station === 'CNC-02' && p.signal === 'vibration');
|
||||
if (pred) {
|
||||
console.log(` latest projection: ${pred.label} ${pred.current.toFixed(2)} -> ${pred.threshold} ${pred.unit} in ${pred.eta} (slope ${pred.slopePerMin.toFixed(4)}/min, r2 ${pred.r2.toFixed(3)})`);
|
||||
check('projection quality is reported and usable', pred.r2 > 0.55, `r2 ${pred.r2.toFixed(3)}`);
|
||||
check('projection slope is positive', pred.slopePerMin > 0);
|
||||
}
|
||||
|
||||
// Latching: feed a steady state and confirm the alarm set stops churning.
|
||||
const seenSets = [];
|
||||
for (let i = 0; i < 240; i++) {
|
||||
line.step(0.5);
|
||||
out = engine.update(line.snapshot());
|
||||
seenSets.push(out.alarms.map((a) => a.key).sort().join('|'));
|
||||
}
|
||||
const distinct = new Set(seenSets).size;
|
||||
console.log(` alarm-set changes over 120 s of steady fault: ${distinct} distinct sets`);
|
||||
check('alarm list does not flap', distinct <= 6, `${distinct} distinct sets`);
|
||||
check('the bearing alarm is present and severe',
|
||||
out.alarms.some((a) => a.station === 'CNC-02' && (a.severity === 'major' || a.severity === 'critical')),
|
||||
out.alarms.filter((a) => a.station === 'CNC-02').map((a) => `${a.severity}:${a.kind}`).join(', '));
|
||||
|
||||
// Clearing the fault must retire the alarms rather than leaving them stuck on.
|
||||
line.clearFault('bearing-degradation');
|
||||
line.toolChange();
|
||||
feed(400);
|
||||
check('alarms clear after the fault is resolved',
|
||||
!out.alarms.some((a) => a.station === 'CNC-02'),
|
||||
out.alarms.map((a) => a.key).join(', ') || 'clean');
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user