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