/** * The simulated telemetry source: owns the model, the clock, and the analytics. * * Real time advances at TICK_MS. Simulated time advances at TICK_MS * speed, so * the operator can run the plant at 60x and watch a twenty-minute degradation * play out in twenty seconds. Nothing downstream knows or cares. */ import { ProductionLine } from '../sim/line.js'; import { AnalyticsEngine } from '../analytics/alarms.js'; import { TelemetrySource } from './source.js'; /** Real milliseconds between broadcast frames. */ export const TICK_MS = 500; /** Selectable clock multipliers. 0 is paused. */ export const SPEEDS = [0, 1, 5, 20, 60]; /** * Simulated seconds to run before serving the first frame. * * The demo must open on a plant that has been running, not one that just booted. * Without this the rolling OEE window contains a few seconds of loss-free data and * reads a perfect 100%, which is precisely the "obviously fabricated" impression * the model works hard to avoid; the learned anomaly baselines are also not ready, * so nothing can be detected for the first several minutes. */ export const PREWARM_SEC = 1800; export class SimulatedSource extends TelemetrySource { constructor({ seed, prewarmSec } = {}) { super('simulated'); this.line = new ProductionLine(seed); this.analytics = new AnalyticsEngine(); this.speed = 1; this.timer = null; this.prewarmSec = Number.isFinite(prewarmSec) ? prewarmSec : PREWARM_SEC; } get capabilities() { return { timeControl: true, faultInjection: true, setpointControl: true }; } async start() { if (this.timer) return; if (this.prewarmSec > 0) { const t0 = Date.now(); const step = (TICK_MS / 1000); const iterations = Math.floor(this.prewarmSec / step); // Run through the normal tick path so the replay ring and the analytics // baselines end up in exactly the state they would reach organically. for (let i = 0; i < iterations; i++) this.tick(step); console.log( `[source] pre-warmed ${(this.prewarmSec / 60).toFixed(0)} simulated minutes in ${Date.now() - t0} ms`, ); } else { // At minimum emit one frame so a connecting client has something to render. this.tick(0); } this.timer = setInterval(() => this.tick((TICK_MS / 1000) * this.speed), TICK_MS); } async stop() { if (this.timer) clearInterval(this.timer); this.timer = null; } tick(simSeconds) { if (simSeconds > 0) this.line.step(simSeconds); const snap = this.line.snapshot(); const analytics = this.analytics.update(snap); this.emit({ ...snap, analytics, events: this.line.events.slice(-40).reverse(), sim: { speed: this.speed, paused: this.speed === 0, speeds: SPEEDS, tickMs: TICK_MS, source: this.name, }, }); } setSpeed(speed) { const s = Number(speed); if (!SPEEDS.includes(s)) throw new Error(`Unsupported speed ${speed}. Allowed: ${SPEEDS.join(', ')}`); this.speed = s; this.line.logEvent('action', 'LINE-1', s === 0 ? 'Simulation paused.' : `Simulation speed set to ${s}x.`); return s; } setSetpoint(v) { return this.line.setSetpoint(v); } setLineSpeed(v) { return this.line.setLineSpeed(v); } injectFault(id) { return this.line.injectFault(id); } clearFault(id) { return this.line.clearFault(id); } toolChange() { return this.line.toolChange(); } reset() { this.line.reset(); // The learned baselines belong to the old run; keeping them would flag the // fresh line as anomalous. this.analytics.reset(); this.replay = []; // Re-warm, or Reset would leave the dashboard showing a perfect 100% OEE and // an anomaly detector with nothing learned - worse than before the reset. if (this.prewarmSec > 0) { const step = TICK_MS / 1000; const iterations = Math.floor(this.prewarmSec / step); for (let i = 0; i < iterations; i++) this.tick(step); } else { this.tick(0); } } }