Initial commit

This commit is contained in:
2026-08-24 15:35:32 +05:30
commit fcade251a6
51 changed files with 11565 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
/**
* End-to-end server verification: WebSocket streaming, the control API, and the
* copilot (including its offline fallback).
*
* Start the server first, then: node scripts/apicheck.mjs
*/
import WebSocket from 'ws';
const BASE = process.env.BASE || 'http://localhost:8787';
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures++;
console.log(` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ' ' + detail : ''}`);
}
async function post(path, body) {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
return { status: res.status, json: await res.json().catch(() => null) };
}
async function get(path) {
const res = await fetch(`${BASE}${path}`);
return { status: res.status, json: await res.json().catch(() => null) };
}
/** Collect n frames from the websocket. */
function collectFrames(n, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${BASE.replace('http', 'ws')}/ws`);
const frames = [];
const timer = setTimeout(() => { ws.close(); reject(new Error(`timed out after ${frames.length} frames`)); }, timeoutMs);
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'frame') frames.push(msg.frame);
if (frames.length >= n) { clearTimeout(timer); ws.close(); resolve(frames); }
});
ws.on('error', (err) => { clearTimeout(timer); reject(err); });
});
}
/** Read an SSE copilot response to completion. */
async function askCopilot(question, timeoutMs = 120000) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await fetch(`${BASE}/api/copilot/chat`, {
method: 'POST',
signal: ctl.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, history: [] }),
});
let text = '';
let meta = null;
let notes = [];
let buf = '';
const dec = new TextDecoder();
for await (const chunk of res.body) {
buf += dec.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 2);
if (!line.startsWith('data:')) continue;
const obj = JSON.parse(line.slice(5).trim());
if (obj.type === 'token') text += obj.text;
if (obj.type === 'meta') meta = obj;
if (obj.type === 'note') notes.push(obj.text);
}
}
return { text, meta, notes };
} finally {
clearTimeout(timer);
}
}
console.log(`\n=== A. Metadata and health (${BASE}) ===`);
const meta = await get('/api/meta');
check('GET /api/meta returns 200', meta.status === 200);
check('five stations described', meta.json?.stations?.length === 5, (meta.json?.stations || []).map((s) => s.id).join(','));
check('four fault profiles offered', meta.json?.faults?.length === 4);
check('source reports its capabilities', Boolean(meta.json?.source?.capabilities?.faultInjection));
console.log(` copilot: ${meta.json?.copilot?.label} (${meta.json?.copilot?.detail})`);
const health = await get('/api/health');
check('GET /api/health ok', health.status === 200 && health.json.ok === true);
console.log('\n=== B. WebSocket streaming ===');
{
await post('/api/control/speed', { speed: 1 });
const frames = await collectFrames(3);
check('received 3 frames', frames.length === 3);
const f = frames[frames.length - 1];
check('frame has stations, kpi, analytics', Boolean(f.stations && f.kpi && f.analytics));
check('frame carries analytics arrays',
Array.isArray(f.analytics.alarms) && Array.isArray(f.analytics.predictions));
check('simulated time advances between frames', frames[2].t > frames[0].t,
`${frames[0].t.toFixed(1)}s -> ${frames[2].t.toFixed(1)}s`);
check('every station reports a state', f.stations.every((s) => typeof s.state === 'string'));
check('sim block reports speed and tick', f.sim.tickMs > 0 && typeof f.sim.speed === 'number');
}
console.log('\n=== C. Control API ===');
{
const sp = await post('/api/control/setpoint', { value: 325 });
check('POST setpoint accepted', sp.status === 200 && sp.json.result === 325, JSON.stringify(sp.json));
const clamp = await post('/api/control/setpoint', { value: 9999 });
check('setpoint is clamped, not rejected', clamp.json.result === 360, `got ${clamp.json.result}`);
const ls = await post('/api/control/line-speed', { value: 115 });
check('POST line-speed accepted', ls.json.result === 115);
const speed = await post('/api/control/speed', { speed: 60 });
check('POST speed 60x accepted', speed.json.result === 60);
const badSpeed = await post('/api/control/speed', { speed: 999 });
check('invalid speed rejected with 400', badSpeed.status === 400, badSpeed.json?.error);
const badFault = await post('/api/control/fault', { id: 'nope', action: 'inject' });
check('unknown fault rejected with 400', badFault.status === 400, badFault.json?.error);
await post('/api/control/setpoint', { value: 305 });
await post('/api/control/line-speed', { value: 100 });
}
console.log('\n=== D. Fault injection propagates to the stream ===');
{
await post('/api/control/speed', { speed: 60 });
await post('/api/control/fault', { id: 'packer-jam', action: 'inject' });
const frames = await collectFrames(8);
const last = frames[frames.length - 1];
const pkg = last.stations.find((s) => s.id === 'PKG-05');
check('packer shows fault state in the stream', pkg.state === 'fault', pkg.state);
check('a critical alarm is raised',
last.analytics.alarms.some((a) => a.severity === 'critical'),
last.analytics.alarms.map((a) => `${a.severity}:${a.station}`).join(', '));
check('fault is listed on the frame', last.faults.some((f) => f.id === 'packer-jam'));
await post('/api/control/fault', { id: 'packer-jam', action: 'clear' });
const after = await collectFrames(6);
const pkg2 = after[after.length - 1].stations.find((s) => s.id === 'PKG-05');
check('packer recovers after the fault is cleared', pkg2.state !== 'fault', pkg2.state);
}
console.log('\n=== E. Copilot ===');
{
// Build up a real condition so the answers have something to work with.
await post('/api/control/fault', { id: 'bearing-degradation', action: 'inject' });
await post('/api/control/speed', { speed: 60 });
await new Promise((r) => setTimeout(r, 12000));
await post('/api/control/speed', { speed: 1 });
const state = await get('/api/state');
const vib = state.json.stations.find((s) => s.id === 'CNC-02').signals.vibration;
console.log(` CNC-02 vibration is now ${vib.toFixed(2)} mm/s; alarms: ${state.json.analytics.alarms.length}; predictions: ${state.json.analytics.predictions.length}`);
const qs = [
'What is wrong with CNC-02?',
'Why is OEE down?',
'Draft a maintenance work order.',
'What happens if I raise line speed by 10%?',
];
for (const q of qs) {
const ans = await askCopilot(q);
const ok = ans.text.trim().length > 40;
check(`answered: "${q}"`, ok, ok ? `${ans.text.trim().length} chars via ${ans.meta?.provider}` : JSON.stringify(ans).slice(0, 200));
if (ans.notes.length) console.log(` note: ${ans.notes[0]}`);
console.log(` -> ${ans.text.trim().split('\n')[0].slice(0, 160)}`);
}
// The answer must be grounded in real telemetry, not generic prose.
const diag = await askCopilot('What is wrong with CNC-02?');
check('answer cites the actual station', /CNC-02/i.test(diag.text));
check('answer cites a real measurement', /\d+\.\d+/.test(diag.text));
await post('/api/control/fault', { id: 'bearing-degradation', action: 'clear' });
await post('/api/control/reset', {});
}
console.log(`\n${failures === 0 ? 'ALL API CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`);
process.exit(failures === 0 ? 0 : 1);
+64
View File
@@ -0,0 +1,64 @@
/**
* Run the server and the web dev server together, with one Ctrl+C to stop both.
*
* A tiny spawner instead of a dependency like concurrently: two fewer packages to
* install, and it behaves predictably on Windows, which is where this demo runs.
*/
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const targets = [
{ name: 'server', args: ['run', 'dev', '-w', 'server'], color: '\x1b[36m' },
{ name: 'web', args: ['run', 'dev', '-w', 'web'], color: '\x1b[35m' },
];
const children = [];
let shuttingDown = false;
for (const t of targets) {
const child = spawn(npm, t.args, { cwd: root, shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
children.push(child);
const prefix = `${t.color}[${t.name}]\x1b[0m `;
const pipe = (stream) => {
let buf = '';
stream.on('data', (chunk) => {
buf += chunk.toString();
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
if (line.trim()) process.stdout.write(prefix + line + '\n');
}
});
};
pipe(child.stdout);
pipe(child.stderr);
child.on('exit', (code) => {
if (shuttingDown) return;
console.log(`${prefix}exited with code ${code}. Shutting down.`);
shutdown();
});
}
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
for (const c of children) {
if (!c.killed) c.kill();
}
setTimeout(() => process.exit(0), 300);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
console.log('\n Digital twin demo starting…');
console.log(' Dashboard: \x1b[1mhttp://localhost:5173\x1b[0m');
console.log(' API: http://localhost:8787\n');
+322
View File
@@ -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);