Initial commit with Dockerfile and demo code
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Builds the plant context handed to the copilot.
|
||||
*
|
||||
* Two rules govern what goes in here.
|
||||
*
|
||||
* First: send CONCLUSIONS, not raw floats. A 4B local model reasons well over
|
||||
* "vibration is rising 0.04 mm/s per minute and reaches its 4.5 limit in about
|
||||
* 7 minutes" and very badly over six hundred numbers. The analytics layer has
|
||||
* already done the arithmetic; the model's job is explanation and judgement.
|
||||
*
|
||||
* Second: the copilot is NOT told which fault was injected. Operator injection
|
||||
* log lines are filtered out, so the model has to diagnose from telemetry the
|
||||
* way it would in a real plant. It is a weaker demo if the model can read the
|
||||
* answer off a label, and a dishonest one.
|
||||
*/
|
||||
|
||||
import { STATION_SPECS } from '../sim/stations.js';
|
||||
import { formatDuration } from '../analytics/trend.js';
|
||||
|
||||
function fmt(v, precision = 1) {
|
||||
return Number.isFinite(v) ? v.toFixed(precision) : 'n/a';
|
||||
}
|
||||
|
||||
function pct(v) {
|
||||
return Number.isFinite(v) ? `${(v * 100).toFixed(1)}%` : 'n/a';
|
||||
}
|
||||
|
||||
/** Where a value sits relative to its declared thresholds. */
|
||||
function thresholdNote(g, v) {
|
||||
if (!Number.isFinite(v)) return '';
|
||||
if (g.alarmHigh !== undefined && v >= g.alarmHigh) return ` [ALARM, limit ${g.alarmHigh}]`;
|
||||
if (g.warnHigh !== undefined && v >= g.warnHigh) return ` [WARN, limit ${g.warnHigh}]`;
|
||||
if (g.alarmLow !== undefined && v <= g.alarmLow) return ` [ALARM, limit ${g.alarmLow}]`;
|
||||
if (g.warnLow !== undefined && v <= g.warnLow) return ` [WARN, limit ${g.warnLow}]`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the context block. Returns plain text, kept deliberately compact.
|
||||
*/
|
||||
export function buildContext(frame) {
|
||||
if (!frame) return 'No telemetry available yet.';
|
||||
|
||||
const L = [];
|
||||
const a = frame.analytics || { alarms: [], predictions: [], trends: [] };
|
||||
|
||||
L.push(`PLANT CONTEXT - line ${frame.lineId}`);
|
||||
L.push(`Simulated run time: ${formatDuration(frame.t)} (clock running at ${frame.sim ? frame.sim.speed : 1}x)`);
|
||||
L.push('');
|
||||
|
||||
// --- KPIs, with the factor breakdown so the model can attribute the loss ---
|
||||
const k = frame.kpi;
|
||||
L.push('OEE (rolling 20 simulated minutes):');
|
||||
L.push(` OEE ${pct(k.oee)} = Availability ${pct(k.availability)} x Performance ${pct(k.performance)} x Quality ${pct(k.quality)}`);
|
||||
L.push(` Throughput ${fmt(k.throughputPerHour, 0)} good units/hour, scrap ${pct(k.scrapRate)}`);
|
||||
L.push(` Energy ${fmt(k.energyKw)} kW, ${fmt(k.energyPerUnit, 3)} kWh per unit`);
|
||||
L.push(` Totals since reset: ${k.produced} produced, ${k.good} good, ${k.rejected} rejected`);
|
||||
L.push('');
|
||||
|
||||
// --- State vocabulary ---
|
||||
// Without this the model guesses. Asked why OEE was down it invented "safety
|
||||
// logic that stalls the spindle" and attributed micro-stops to Availability
|
||||
// instead of Performance. These are definitions, not hints.
|
||||
L.push('STATION STATE MEANINGS:');
|
||||
L.push(' running - producing normally.');
|
||||
L.push(' starved - idle because its input buffer is empty (the constraint is UPSTREAM).');
|
||||
L.push(' blocked - idle because its output buffer is full (the constraint is DOWNSTREAM).');
|
||||
L.push(' microstop - a brief stall of a few seconds. Normal line behaviour, counts against PERFORMANCE, not Availability.');
|
||||
L.push(' down - an unplanned stop of tens of seconds. Counts against AVAILABILITY.');
|
||||
L.push(' fault - stopped on a specific fault condition. Counts against AVAILABILITY.');
|
||||
L.push('');
|
||||
L.push('HOW OEE LOSS IS ATTRIBUTED ON THIS LINE:');
|
||||
L.push(' Availability loss = time in "down" or "fault" states only.');
|
||||
L.push(' Performance loss = micro-stops and running below the ideal 4.4 s bottleneck cycle.');
|
||||
L.push(' Quality loss = parts rejected at INS-04.');
|
||||
L.push(' "starved" and "blocked" are not losses in their own right; they are the consequence of a stoppage somewhere else on the line.');
|
||||
L.push('');
|
||||
|
||||
// --- Station states and signals ---
|
||||
L.push('STATIONS (in process order):');
|
||||
for (const spec of STATION_SPECS) {
|
||||
const st = frame.stations.find((s) => s.id === spec.id);
|
||||
if (!st) continue;
|
||||
const stale = st.online ? '' : ' (NOT REPORTING - values are last known)';
|
||||
L.push(` ${st.id} ${st.name} - state: ${st.state}${stale}`);
|
||||
const parts = [];
|
||||
for (const g of spec.signals) {
|
||||
if (g.key === 'partsInspected' || g.key === 'downtime') continue;
|
||||
const v = st.signals[g.key];
|
||||
parts.push(`${g.label} ${fmt(v, g.precision)} ${g.unit}${thresholdNote(g, v)}`);
|
||||
}
|
||||
L.push(` ${parts.join('; ')}`);
|
||||
}
|
||||
L.push('');
|
||||
|
||||
// --- WIP, which is how blocking and starvation become legible ---
|
||||
const bufs = frame.buffers
|
||||
.map((b, i) => `${STATION_SPECS[i].id}->${STATION_SPECS[i + 1].id}: ${b}/${frame.bufferCapacity}`)
|
||||
.join(', ');
|
||||
L.push(`WIP BUFFERS: ${bufs}`);
|
||||
L.push(`Operator setpoints: oven ${frame.controls.setpoint} C, line speed ${frame.controls.lineSpeedPct}%`);
|
||||
L.push('');
|
||||
|
||||
// --- Alarms ---
|
||||
if (a.alarms.length) {
|
||||
L.push('ACTIVE ALARMS (most severe first):');
|
||||
for (const al of a.alarms.slice(0, 12)) {
|
||||
L.push(` [${al.severity.toUpperCase()}] ${al.station}${al.signal ? '.' + al.signal : ''}: ${al.message}`);
|
||||
}
|
||||
} else {
|
||||
L.push('ACTIVE ALARMS: none.');
|
||||
}
|
||||
L.push('');
|
||||
|
||||
// --- Trend projections ---
|
||||
if (a.predictions.length) {
|
||||
L.push('TREND PROJECTIONS (least-squares fit over recent run time, extrapolated):');
|
||||
for (const p of a.predictions) {
|
||||
L.push(` ${p.station}.${p.signal} (${p.label}): now ${fmt(p.current, 2)} ${p.unit}, rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min, reaches limit ${p.threshold} ${p.unit} in about ${p.eta} of run time (fit quality r2=${p.r2.toFixed(2)})`);
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
|
||||
// --- Notable slopes, even where no threshold crossing is projected ---
|
||||
const notable = (a.trends || [])
|
||||
.filter((t) => Math.abs(t.slopePerMin) > 1e-4 && t.r2 > 0.5)
|
||||
.sort((x, y) => y.r2 - x.r2)
|
||||
.slice(0, 6);
|
||||
if (notable.length) {
|
||||
L.push('OTHER MEASURED TRENDS:');
|
||||
for (const t of notable) {
|
||||
const dir = t.slopePerMin > 0 ? 'rising' : 'falling';
|
||||
L.push(` ${t.station}.${t.signal} (${t.label}): ${dir} ${Math.abs(t.slopePerMin).toFixed(4)} ${t.unit}/min (r2=${t.r2.toFixed(2)})`);
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
|
||||
// --- Recent events, with operator fault injections filtered out ---
|
||||
const events = (frame.events || []).filter((e) => e.kind !== 'inject').slice(0, 8);
|
||||
if (events.length) {
|
||||
L.push('RECENT EVENTS (newest first):');
|
||||
for (const e of events) {
|
||||
L.push(` t=${formatDuration(e.t)} [${e.kind}] ${e.station}: ${e.message}`);
|
||||
}
|
||||
L.push('');
|
||||
}
|
||||
|
||||
// --- Model relationships, so the copilot can reason causally ---
|
||||
L.push('KNOWN PROCESS RELATIONSHIPS on this line:');
|
||||
L.push(' - CNC-02 bearing vibration accelerates tool wear, and both push parts out of tolerance, raising INS-04 reject rate.');
|
||||
L.push(' - High CNC-02 vibration also causes spindle chatter, which stalls the cut and costs Performance.');
|
||||
L.push(' - OVN-03 zone 2 is the control zone. If actual temperature deviates from setpoint the cure is out of spec and INS-04 reject rate rises.');
|
||||
L.push(' - If OVN-03 burner duty is saturated at 100% and zone 2 is still below setpoint, the oven has lost heating capacity.');
|
||||
L.push(' - Stations are linked by finite WIP buffers. A stopped station fills the buffers behind it, so upstream stations become "blocked"; downstream stations become "starved".');
|
||||
L.push(' - CNC-02 has the longest cycle time (4.4 s), so it is the line bottleneck.');
|
||||
|
||||
return L.join('\n');
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = `You are the plant copilot for a manufacturing digital twin of production line LINE-1.
|
||||
|
||||
You are given a live telemetry context: station states, sensor readings with their alarm limits, OEE with its Availability/Performance/Quality breakdown, WIP buffer levels, active alarms, and measured trend projections.
|
||||
|
||||
How to answer:
|
||||
- Be concise and concrete. Two to five short sentences for most questions, or a short bullet list. This is read on a factory dashboard, not in a report.
|
||||
- Always ground claims in the specific numbers from the context. Cite the station and the value.
|
||||
- Reason causally using the stated process relationships. Explain WHY, not just WHAT.
|
||||
- Distinguish a measurement from an inference. "Vibration is 4.2 mm/s" is a measurement; "the spindle bearing is degrading" is an inference from it.
|
||||
- The trend projections are straight-line extrapolations of recent data, not certainties. Present them as "at the current rate", never as a guarantee.
|
||||
- If the context does not contain what you need, say so plainly. Do not invent readings, part numbers, timestamps, or history you were not given.
|
||||
- Do NOT invent mechanisms. Explain causes only using the process relationships and state definitions given below. Never assert control logic, safety interlocks, PLC behaviour or physical mechanisms that are not stated there — a plausible-sounding invented mechanism is the single worst failure mode here, because a plant engineer will catch it.
|
||||
- Attribute OEE losses strictly according to the attribution rules given below. Do not guess which factor a loss belongs to.
|
||||
- Times are in simulated run time, because the twin can run faster than real time. Say "of run time" when quoting a projection.
|
||||
- Never claim to have taken an action. You are advisory; the operator drives the line.`;
|
||||
@@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Copilot provider layer.
|
||||
*
|
||||
* Resolution order: OpenRouter if a key is set, else Ollama if it answers, else
|
||||
* the deterministic rule-based fallback. The fallback is not an error path - it
|
||||
* is a supported mode, and COPILOT_PROVIDER=fallback selects it deliberately.
|
||||
*
|
||||
* The hard requirement is that a question ALWAYS gets an answer. A missing key,
|
||||
* an unreachable host, a model that 404s, a stream that dies halfway: every one
|
||||
* of those degrades to the fallback rather than surfacing an error on stage.
|
||||
*/
|
||||
|
||||
import { buildContext, SYSTEM_PROMPT } from './context.js';
|
||||
import { answerFromRules } from './rules.js';
|
||||
import { loadSettings, saveSettings, validatePatch, resetSettings } from './settings.js';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 2000;
|
||||
const REQUEST_TIMEOUT_MS = 90000;
|
||||
|
||||
/**
|
||||
* Turn whatever OLLAMA_HOST happens to contain into a dialable origin.
|
||||
*
|
||||
* Ollama itself commonly sets OLLAMA_HOST=0.0.0.0 machine-wide to bind all
|
||||
* interfaces. That is a BIND address, not a destination - you cannot connect to
|
||||
* it - and because it is a real environment variable it silently overrides any
|
||||
* default we set here. Also accepts a bare host, a host:port, or a full URL.
|
||||
*/
|
||||
export function normalizeOllamaHost(raw) {
|
||||
let h = String(raw || '').trim();
|
||||
if (!h) return 'http://127.0.0.1:11434';
|
||||
if (!/^https?:\/\//i.test(h)) h = `http://${h}`;
|
||||
try {
|
||||
const u = new URL(h);
|
||||
if (u.hostname === '0.0.0.0' || u.hostname === '::' || u.hostname === '[::]') {
|
||||
u.hostname = '127.0.0.1';
|
||||
}
|
||||
if (!u.port) u.port = '11434';
|
||||
return u.origin;
|
||||
} catch {
|
||||
return 'http://127.0.0.1:11434';
|
||||
}
|
||||
}
|
||||
|
||||
export class Copilot {
|
||||
constructor(env = process.env) {
|
||||
this.env = env;
|
||||
// The key stays in the environment only. It is never part of the settings
|
||||
// that the settings page can read or write.
|
||||
this.openRouterKey = (env.OPENROUTER_API_KEY || '').trim();
|
||||
this.applySettings(loadSettings(env));
|
||||
|
||||
this.provider = 'fallback';
|
||||
this.model = null;
|
||||
this.detail = 'not yet detected';
|
||||
this.modelCache = new Map();
|
||||
}
|
||||
|
||||
/** Adopt a settings object. Does not persist; see configure(). */
|
||||
applySettings(settings) {
|
||||
this.settings = { ...settings };
|
||||
this.forced = settings.provider === 'auto' ? null : settings.provider;
|
||||
this.openRouterModel = settings.openRouterModel;
|
||||
this.ollamaModel = settings.ollamaModel;
|
||||
this.ollamaHost = normalizeOllamaHost(settings.ollamaHost);
|
||||
this.temperature = settings.temperature;
|
||||
this.maxTokens = settings.maxTokens;
|
||||
this.reasoningEffort = settings.reasoningEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, apply, persist and re-detect.
|
||||
*
|
||||
* Returns { ok, errors, settings, status }. A rejected patch changes nothing.
|
||||
*/
|
||||
async configure(patch) {
|
||||
const { ok, errors, clean } = validatePatch(patch);
|
||||
if (!ok) return { ok: false, errors, settings: this.publicSettings(), status: this.status() };
|
||||
|
||||
this.applySettings({ ...this.settings, ...clean });
|
||||
saveSettings(this.settings);
|
||||
// The chosen host may have changed, so availability has to be re-probed.
|
||||
const status = await this.detect();
|
||||
return { ok: true, errors: [], settings: this.publicSettings(), status };
|
||||
}
|
||||
|
||||
/** Restore environment defaults, discarding the saved settings file. */
|
||||
async reset() {
|
||||
resetSettings();
|
||||
this.applySettings(loadSettings(this.env));
|
||||
const status = await this.detect();
|
||||
return { ok: true, errors: [], settings: this.publicSettings(), status };
|
||||
}
|
||||
|
||||
/** Settings safe to hand to a browser: never includes the API key. */
|
||||
publicSettings() {
|
||||
return {
|
||||
...this.settings,
|
||||
// Report the normalized host, since that is what actually gets dialled.
|
||||
resolvedOllamaHost: this.ollamaHost,
|
||||
openRouterKeyPresent: Boolean(this.openRouterKey),
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe available providers. Safe to call repeatedly. */
|
||||
async detect() {
|
||||
if (this.forced === 'fallback') {
|
||||
this.provider = 'fallback';
|
||||
this.model = null;
|
||||
this.detail = 'forced by COPILOT_PROVIDER=fallback';
|
||||
return this.status();
|
||||
}
|
||||
|
||||
if (this.openRouterKey && this.forced !== 'ollama') {
|
||||
this.provider = 'openrouter';
|
||||
this.model = this.openRouterModel;
|
||||
this.detail = 'OpenRouter API key present';
|
||||
return this.status();
|
||||
}
|
||||
|
||||
if (this.forced !== 'openrouter') {
|
||||
const reachable = await this.probeOllama();
|
||||
if (reachable) {
|
||||
this.provider = 'ollama';
|
||||
this.model = this.ollamaModel;
|
||||
this.detail = `Ollama at ${this.ollamaHost}${reachable.hasModel ? '' : ` (warning: model "${this.ollamaModel}" not in the local list)`}`;
|
||||
return this.status();
|
||||
}
|
||||
}
|
||||
|
||||
this.provider = 'fallback';
|
||||
this.model = null;
|
||||
this.detail = this.openRouterKey
|
||||
? 'no provider reachable'
|
||||
: `no OPENROUTER_API_KEY and Ollama not reachable at ${this.ollamaHost}`;
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async probeOllama() {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${this.ollamaHost}/api/tags`, { signal: ctl.signal });
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
const names = (body.models || []).map((m) => m.name);
|
||||
return { hasModel: names.includes(this.ollamaModel), names };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
status() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
model: this.model,
|
||||
detail: this.detail,
|
||||
// The UI shows this so you always know on stage what is answering.
|
||||
label: this.provider === 'fallback'
|
||||
? 'Rule-based (offline)'
|
||||
: `${this.provider === 'ollama' ? 'Ollama' : 'OpenRouter'} · ${this.model}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List the models a provider actually offers.
|
||||
*
|
||||
* Cached briefly: OpenRouter returns 400+ models and the settings page may be
|
||||
* opened repeatedly while someone makes up their mind.
|
||||
*/
|
||||
async listModels(provider, { force = false } = {}) {
|
||||
const key = provider;
|
||||
const cached = this.modelCache.get(key);
|
||||
if (!force && cached && Date.now() - cached.at < 5 * 60 * 1000) {
|
||||
return { ...cached.value, cached: true };
|
||||
}
|
||||
|
||||
let value;
|
||||
try {
|
||||
value = provider === 'openrouter'
|
||||
? await this.listOpenRouterModels()
|
||||
: await this.listOllamaModels();
|
||||
} catch (err) {
|
||||
return { provider, models: [], error: err.message, cached: false };
|
||||
}
|
||||
this.modelCache.set(key, { at: Date.now(), value });
|
||||
return { ...value, cached: false };
|
||||
}
|
||||
|
||||
async listOpenRouterModels() {
|
||||
if (!this.openRouterKey) {
|
||||
throw new Error('No OPENROUTER_API_KEY is set, so the model list cannot be fetched.');
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), 15000);
|
||||
try {
|
||||
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
signal: ctl.signal,
|
||||
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`OpenRouter returned HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const models = (body.data || []).map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
contextLength: m.context_length || null,
|
||||
// Prices come back as per-token strings; per-million is what people read.
|
||||
promptPerM: m.pricing && m.pricing.prompt ? Number(m.pricing.prompt) * 1e6 : null,
|
||||
completionPerM: m.pricing && m.pricing.completion ? Number(m.pricing.completion) * 1e6 : null,
|
||||
})).sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { provider: 'openrouter', models };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async listOllamaModels() {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${this.ollamaHost}/api/tags`, { signal: ctl.signal });
|
||||
if (!res.ok) throw new Error(`Ollama returned HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const models = (body.models || []).map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
sizeBytes: m.size || null,
|
||||
parameterSize: m.details ? m.details.parameter_size : null,
|
||||
quantization: m.details ? m.details.quantization_level : null,
|
||||
family: m.details ? m.details.family : null,
|
||||
})).sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { provider: 'ollama', models };
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(`Ollama did not respond at ${this.ollamaHost}. Is "ollama serve" running?`);
|
||||
}
|
||||
throw new Error(`Cannot reach Ollama at ${this.ollamaHost}: ${err.message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure a provider/model with a trivial prompt.
|
||||
*
|
||||
* Time-to-first-token is the number that matters here, not total time: a model
|
||||
* that takes 30 s to start talking is unusable in front of a customer even if
|
||||
* the answer is excellent. This exists so that judgement can be made from a
|
||||
* measurement rather than a guess, before the meeting rather than during it.
|
||||
*/
|
||||
async testProvider({ provider, model } = {}) {
|
||||
const resolved = this.resolve(provider);
|
||||
const started = Date.now();
|
||||
|
||||
if (resolved === 'fallback') {
|
||||
// Nothing to probe: the rule engine is in-process and cannot be unavailable.
|
||||
return {
|
||||
ok: true, provider: resolved, model: null,
|
||||
firstTokenMs: Date.now() - started, totalMs: Date.now() - started,
|
||||
chars: 0,
|
||||
sample: 'Rule engine ready. No model, no network, no failure mode.',
|
||||
};
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: 'You are a terse test endpoint. Reply with exactly: READY' },
|
||||
{ role: 'user', content: 'Reply with exactly: READY' },
|
||||
];
|
||||
|
||||
let firstTokenMs = null;
|
||||
let text = '';
|
||||
try {
|
||||
const iter = resolved === 'openrouter'
|
||||
? this.streamOpenRouter(messages, model)
|
||||
: this.streamOllama(messages, model);
|
||||
for await (const chunk of iter) {
|
||||
if (!chunk) continue;
|
||||
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
||||
text += chunk;
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false, provider: resolved,
|
||||
model: model || (resolved === 'openrouter' ? this.openRouterModel : this.ollamaModel),
|
||||
error: err.message, totalMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
return {
|
||||
ok: trimmed.length > 0,
|
||||
provider: resolved,
|
||||
model: model || (resolved === 'openrouter' ? this.openRouterModel : this.ollamaModel),
|
||||
firstTokenMs,
|
||||
totalMs: Date.now() - started,
|
||||
chars: trimmed.length,
|
||||
sample: trimmed.slice(0, 120),
|
||||
error: trimmed.length === 0
|
||||
? 'The model returned an empty response. It may be spending its whole token budget on reasoning — try a higher max tokens or a lower reasoning effort.'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
buildMessages(question, history, frame) {
|
||||
const context = buildContext(frame);
|
||||
const msgs = [{ role: 'system', content: `${SYSTEM_PROMPT}\n\n---\n\n${context}` }];
|
||||
// Keep only the last few turns: a 4B model with a long context degrades fast,
|
||||
// and the plant context is refreshed every turn anyway.
|
||||
for (const m of (history || []).slice(-6)) {
|
||||
if (m && (m.role === 'user' || m.role === 'assistant') && m.content) {
|
||||
msgs.push({ role: m.role, content: String(m.content).slice(0, 4000) });
|
||||
}
|
||||
}
|
||||
msgs.push({ role: 'user', content: question });
|
||||
return msgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an answer. Yields { type: 'meta'|'token'|'done'|'note' } objects.
|
||||
*
|
||||
* Any provider failure yields a 'note' explaining the degradation and then
|
||||
* streams the fallback answer, so the caller never has to handle an error.
|
||||
*/
|
||||
/**
|
||||
* Which provider to actually use for one request.
|
||||
*
|
||||
* A per-request override exists for a practical reason: a strong reasoning
|
||||
* model can take 15-20 s to first token, which is dead air in front of a
|
||||
* customer, while a local 4B model answers in about 2 s with shallower
|
||||
* analysis. Being able to pick per question - fast for the live walkthrough,
|
||||
* deep for the follow-up discussion - is worth the small amount of plumbing.
|
||||
*/
|
||||
resolve(override) {
|
||||
const want = String(override || '').trim().toLowerCase();
|
||||
if (!want || want === 'auto') return this.provider;
|
||||
if (want === 'fallback') return 'fallback';
|
||||
if (want === 'openrouter' && this.openRouterKey) return 'openrouter';
|
||||
if (want === 'ollama') return 'ollama';
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
statusFor(provider) {
|
||||
if (provider === 'fallback') {
|
||||
return { provider, model: null, detail: 'deterministic rule engine', label: 'Rule-based (offline)' };
|
||||
}
|
||||
if (provider === 'openrouter') {
|
||||
return { provider, model: this.openRouterModel, detail: 'OpenRouter', label: `OpenRouter · ${this.openRouterModel}` };
|
||||
}
|
||||
return { provider, model: this.ollamaModel, detail: `Ollama at ${this.ollamaHost}`, label: `Ollama · ${this.ollamaModel}` };
|
||||
}
|
||||
|
||||
async *stream(question, history, frame, override) {
|
||||
const q = String(question || '').trim();
|
||||
const provider = this.resolve(override);
|
||||
|
||||
if (!q) {
|
||||
yield { type: 'meta', ...this.statusFor(provider) };
|
||||
yield { type: 'token', text: 'Ask me something about the line.' };
|
||||
yield { type: 'done' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'fallback') {
|
||||
yield { type: 'meta', ...this.statusFor(provider) };
|
||||
const { text } = answerFromRules(q, frame);
|
||||
yield { type: 'token', text };
|
||||
yield { type: 'done' };
|
||||
return;
|
||||
}
|
||||
|
||||
yield { type: 'meta', ...this.statusFor(provider) };
|
||||
const messages = this.buildMessages(q, history, frame);
|
||||
|
||||
let produced = '';
|
||||
let failure = null;
|
||||
try {
|
||||
const iter = provider === 'openrouter'
|
||||
? this.streamOpenRouter(messages)
|
||||
: this.streamOllama(messages);
|
||||
for await (const text of iter) {
|
||||
if (!text) continue;
|
||||
produced += text;
|
||||
yield { type: 'token', text };
|
||||
}
|
||||
} catch (err) {
|
||||
failure = err && err.message ? err.message : String(err);
|
||||
console.error('[copilot] provider failed:', failure);
|
||||
}
|
||||
|
||||
// An empty answer is a failure even when nothing threw. A thinking model that
|
||||
// exhausts its token budget mid-reasoning returns a clean 200 with no content,
|
||||
// and a blank panel is the worst possible outcome in front of a customer.
|
||||
if (!produced.trim()) {
|
||||
yield {
|
||||
type: 'note',
|
||||
text: failure
|
||||
? `${provider} failed (${failure}). Answering from the built-in rule engine instead.`
|
||||
: `${provider} returned an empty answer. Answering from the built-in rule engine instead.`,
|
||||
};
|
||||
const { text } = answerFromRules(q, frame);
|
||||
yield { type: 'token', text };
|
||||
} else if (failure) {
|
||||
yield { type: 'note', text: `Stream ended early: ${failure}` };
|
||||
}
|
||||
yield { type: 'done' };
|
||||
}
|
||||
|
||||
// --- providers -----------------------------------------------------------
|
||||
|
||||
async *streamOpenRouter(messages, modelOverride) {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const body = {
|
||||
model: modelOverride || this.openRouterModel,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: this.temperature,
|
||||
// Reasoning tokens count against max_tokens, so a reasoning model on a
|
||||
// tight budget burns the lot thinking and streams back nothing at all.
|
||||
// Keeping the budget generous and reasoning minimal stops the
|
||||
// intermittent empty answers and cuts time-to-first-token, which is the
|
||||
// difference between a usable and an awkward live demo.
|
||||
max_tokens: this.maxTokens,
|
||||
};
|
||||
if (this.reasoningEffort !== 'none') {
|
||||
body.reasoning = { effort: this.reasoningEffort };
|
||||
}
|
||||
|
||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
signal: ctl.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.openRouterKey}`,
|
||||
'X-Title': 'Digital Twin Demo',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
const strip = makeThinkFilter();
|
||||
for await (const line of readLines(res.body)) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
let json;
|
||||
try { json = JSON.parse(payload); } catch { continue; }
|
||||
const delta = json.choices && json.choices[0] && json.choices[0].delta;
|
||||
if (delta && delta.content) yield strip(delta.content);
|
||||
}
|
||||
yield strip(null); // flush
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to Ollama. `think` of null omits the field entirely.
|
||||
*/
|
||||
postOllama(messages, think, signal, modelOverride) {
|
||||
const body = {
|
||||
model: modelOverride || this.ollamaModel,
|
||||
messages,
|
||||
stream: true,
|
||||
options: { temperature: this.temperature, num_predict: this.maxTokens },
|
||||
};
|
||||
if (think !== null) body.think = think;
|
||||
return fetch(`${this.ollamaHost}/api/chat`, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async *streamOllama(messages, modelOverride) {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
// Ollama's `think` is a boolean, so the four-level effort setting maps onto
|
||||
// it: none/low disable reasoning, medium/high enable it. The Qwen3 family
|
||||
// and other thinking models will otherwise spend the entire token budget
|
||||
// inside a reasoning block and return EMPTY content (done_reason:
|
||||
// "length") - a blank copilot panel on stage - and cost ~10x the latency.
|
||||
const think = this.reasoningEffort === 'medium' || this.reasoningEffort === 'high';
|
||||
let res = await this.postOllama(messages, think, ctl.signal, modelOverride);
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
// Models with no reasoning mode reject the flag; retry without it.
|
||||
if (/think/i.test(errBody)) {
|
||||
res = await this.postOllama(messages, null, ctl.signal, modelOverride);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
} else {
|
||||
throw new Error(`HTTP ${res.status} ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const strip = makeThinkFilter();
|
||||
for await (const line of readLines(res.body)) {
|
||||
if (!line.trim()) continue;
|
||||
let json;
|
||||
try { json = JSON.parse(line); } catch { continue; }
|
||||
if (json.error) throw new Error(json.error);
|
||||
if (json.message && json.message.content) yield strip(json.message.content);
|
||||
if (json.done) break;
|
||||
}
|
||||
yield strip(null); // flush
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Async line reader over a fetch response body stream. */
|
||||
async function* readLines(body) {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
for await (const chunk of body) {
|
||||
buf += decoder.decode(chunk, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n')) >= 0) {
|
||||
yield buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
if (buf) yield buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress <think>...</think> reasoning blocks.
|
||||
*
|
||||
* Several strong local models (the Qwen3 family among them) emit a reasoning
|
||||
* block before the answer. Streamed verbatim onto a dashboard it looks like the
|
||||
* product is malfunctioning, so it is filtered out. Call with null to flush.
|
||||
*/
|
||||
function makeThinkFilter() {
|
||||
const OPEN = '<think>';
|
||||
const CLOSE = '</think>';
|
||||
let inThink = false;
|
||||
let buf = '';
|
||||
|
||||
return (chunk) => {
|
||||
if (chunk === null) {
|
||||
const tail = inThink ? '' : buf;
|
||||
buf = '';
|
||||
return tail;
|
||||
}
|
||||
buf += chunk;
|
||||
let out = '';
|
||||
|
||||
for (;;) {
|
||||
if (!inThink) {
|
||||
const i = buf.indexOf(OPEN);
|
||||
if (i === -1) {
|
||||
// Hold back a few characters in case a tag straddles two chunks.
|
||||
const keep = Math.max(0, buf.length - (OPEN.length - 1));
|
||||
out += buf.slice(0, keep);
|
||||
buf = buf.slice(keep);
|
||||
break;
|
||||
}
|
||||
out += buf.slice(0, i);
|
||||
buf = buf.slice(i + OPEN.length);
|
||||
inThink = true;
|
||||
} else {
|
||||
const j = buf.indexOf(CLOSE);
|
||||
if (j === -1) {
|
||||
buf = buf.slice(Math.max(0, buf.length - (CLOSE.length - 1)));
|
||||
break;
|
||||
}
|
||||
buf = buf.slice(j + CLOSE.length);
|
||||
inThink = false;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Deterministic rule-based copilot.
|
||||
*
|
||||
* This is the offline fallback, and it is not a stub. On stage, a dead API key or
|
||||
* bad conference wifi must never turn the copilot panel into an error message, so
|
||||
* this produces a real, grounded, useful answer from the same analytics the LLM
|
||||
* would have seen. It is less fluent and cannot handle open-ended questions, but
|
||||
* it never fails and never invents a number.
|
||||
*
|
||||
* It is also the honesty backstop: everything here is traceable to a threshold or
|
||||
* a regression, so if you are ever asked "is the AI making this up?", you can run
|
||||
* with COPILOT_PROVIDER=fallback and show the same conclusions without a model.
|
||||
*/
|
||||
|
||||
import { STATION_SPECS } from '../sim/stations.js';
|
||||
import { formatDuration } from '../analytics/trend.js';
|
||||
|
||||
const STATION_ALIASES = {
|
||||
'CONV-01': ['conv', 'conveyor', 'infeed', 'belt'],
|
||||
'CNC-02': ['cnc', 'spindle', 'machining', 'mill', 'bearing'],
|
||||
'OVN-03': ['ovn', 'oven', 'cure', 'curing', 'burner', 'zone'],
|
||||
'INS-04': ['ins', 'inspection', 'vision', 'camera', 'reject', 'quality station'],
|
||||
'PKG-05': ['pkg', 'packer', 'packing', 'film', 'wrap'],
|
||||
};
|
||||
|
||||
function pct(v) {
|
||||
return Number.isFinite(v) ? `${(v * 100).toFixed(1)}%` : 'n/a';
|
||||
}
|
||||
|
||||
function resolveStation(q) {
|
||||
const lower = q.toLowerCase();
|
||||
for (const [id, words] of Object.entries(STATION_ALIASES)) {
|
||||
if (lower.includes(id.toLowerCase())) return id;
|
||||
if (words.some((w) => lower.includes(w))) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function signalLabel(stationId, key) {
|
||||
const spec = STATION_SPECS.find((s) => s.id === stationId);
|
||||
const g = spec && spec.signals.find((x) => x.key === key);
|
||||
return g ? g.label : key;
|
||||
}
|
||||
|
||||
/** The causal story behind the alarms currently active, where we can name one. */
|
||||
function causalNarrative(frame) {
|
||||
const a = frame.analytics;
|
||||
const cnc = frame.stations.find((s) => s.id === 'CNC-02');
|
||||
const ovn = frame.stations.find((s) => s.id === 'OVN-03');
|
||||
const ins = frame.stations.find((s) => s.id === 'INS-04');
|
||||
const out = [];
|
||||
|
||||
if (cnc && cnc.signals.vibration > 3.0) {
|
||||
out.push(
|
||||
`CNC-02 bearing vibration is ${cnc.signals.vibration.toFixed(2)} mm/s RMS against a 3.5 warn and 4.5 alarm limit, and spindle load has risen to ${cnc.signals.spindleLoad.toFixed(1)}%. That pattern is bearing degradation: it both stalls the cut, costing Performance, and pushes parts out of tolerance, which is why INS-04 reject rate is ${ins ? ins.signals.rejectRate.toFixed(2) : '?'}%.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (ovn && Math.abs(ovn.signals.tempDeviation) > 6) {
|
||||
const sat = ovn.signals.burnerDuty > 95;
|
||||
out.push(
|
||||
`OVN-03 zone 2 is ${ovn.signals.zone2Temp.toFixed(1)} °C against a ${ovn.signals.setpoint} °C setpoint, a deviation of ${ovn.signals.tempDeviation.toFixed(1)} °C, with burner duty at ${ovn.signals.burnerDuty.toFixed(0)}%.` +
|
||||
(sat
|
||||
? ' Duty is saturated and the zone still cannot reach setpoint, which means the oven has lost heating capacity rather than being mistuned. The cure is out of spec, so reject rate downstream is rising.'
|
||||
: ' The controller is still recovering toward setpoint.'),
|
||||
);
|
||||
}
|
||||
|
||||
const stopped = frame.stations.filter((s) => s.state === 'fault' || s.state === 'down');
|
||||
if (stopped.length) {
|
||||
const blocked = frame.stations.filter((s) => s.state === 'blocked').map((s) => s.id);
|
||||
out.push(
|
||||
`${stopped.map((s) => s.id).join(', ')} ${stopped.length > 1 ? 'are' : 'is'} stopped.` +
|
||||
(blocked.length
|
||||
? ` WIP has backed up behind it and ${blocked.join(', ')} ${blocked.length > 1 ? 'are' : 'is'} now blocked, so the whole line has stopped producing.`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
|
||||
const offline = frame.stations.filter((s) => !s.online);
|
||||
if (offline.length) {
|
||||
out.push(
|
||||
`${offline.map((s) => s.id).join(', ')} ${offline.length > 1 ? 'are' : 'is'} not reporting. The values shown are the last known readings and should not be trusted as live.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!out.length && a.predictions.length) {
|
||||
const p = a.predictions[0];
|
||||
out.push(
|
||||
`Nothing is over limit yet, but ${p.station} ${p.label} is rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min and reaches its ${p.threshold} ${p.unit} limit in about ${p.eta} of run time at the current rate.`,
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function oeeAnswer(frame) {
|
||||
const k = frame.kpi;
|
||||
const factors = [
|
||||
{ name: 'Availability', v: k.availability, why: 'time lost to stoppages' },
|
||||
{ name: 'Performance', v: k.performance, why: 'the line running slower than its ideal cycle, usually micro-stops' },
|
||||
{ name: 'Quality', v: k.quality, why: 'parts rejected at inspection' },
|
||||
].sort((a, b) => a.v - b.v);
|
||||
|
||||
const worst = factors[0];
|
||||
const lines = [
|
||||
`OEE is ${pct(k.oee)} over the last 20 simulated minutes: Availability ${pct(k.availability)} x Performance ${pct(k.performance)} x Quality ${pct(k.quality)}.`,
|
||||
`The biggest loss is ${worst.name} at ${pct(worst.v)} — ${worst.why}.`,
|
||||
];
|
||||
|
||||
const narrative = causalNarrative(frame);
|
||||
if (narrative.length) lines.push(narrative[0]);
|
||||
|
||||
lines.push(`Current output is ${k.throughputPerHour.toFixed(0)} good units/hour with ${pct(k.scrapRate)} scrap.`);
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
function stationAnswer(frame, stationId) {
|
||||
const spec = STATION_SPECS.find((s) => s.id === stationId);
|
||||
const st = frame.stations.find((s) => s.id === stationId);
|
||||
if (!st) return `I have no telemetry for ${stationId}.`;
|
||||
|
||||
const lines = [`${st.id} ${st.name} is currently ${st.state}${st.online ? '' : ' and NOT REPORTING (values below are stale)'}.`];
|
||||
|
||||
const issues = [];
|
||||
for (const g of spec.signals) {
|
||||
const v = st.signals[g.key];
|
||||
if (!Number.isFinite(v)) continue;
|
||||
if (g.alarmHigh !== undefined && v >= g.alarmHigh) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is over its ${g.alarmHigh} alarm limit`);
|
||||
else if (g.warnHigh !== undefined && v >= g.warnHigh) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is over its ${g.warnHigh} warning limit`);
|
||||
else if (g.alarmLow !== undefined && v <= g.alarmLow) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is under its ${g.alarmLow} alarm limit`);
|
||||
else if (g.warnLow !== undefined && v <= g.warnLow) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is under its ${g.warnLow} warning limit`);
|
||||
}
|
||||
|
||||
lines.push(issues.length ? `Out of limits: ${issues.join('; ')}.` : 'All signals are within limits.');
|
||||
|
||||
const preds = frame.analytics.predictions.filter((p) => p.station === stationId);
|
||||
for (const p of preds) {
|
||||
lines.push(`${p.label} is rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min and would reach its ${p.threshold} ${p.unit} limit in about ${p.eta} of run time if the trend holds.`);
|
||||
}
|
||||
|
||||
const narrative = causalNarrative(frame).filter((n) => n.includes(stationId));
|
||||
lines.push(...narrative);
|
||||
|
||||
const readings = spec.signals
|
||||
.filter((g) => g.key !== 'partsInspected' && g.key !== 'downtime')
|
||||
.map((g) => `${g.label} ${st.signals[g.key].toFixed(g.precision)} ${g.unit}`)
|
||||
.join(', ');
|
||||
lines.push(`Current readings: ${readings}.`);
|
||||
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
function predictionAnswer(frame) {
|
||||
const preds = frame.analytics.predictions;
|
||||
if (!preds.length) {
|
||||
return 'No threshold crossings are projected right now. Either nothing is trending toward a limit, or the recent data does not fit a straight line well enough to extrapolate honestly.';
|
||||
}
|
||||
const lines = ['Projected threshold crossings, from a least-squares fit over recent run time:'];
|
||||
for (const p of preds) {
|
||||
lines.push(`- ${p.station} ${p.label}: now ${p.current.toFixed(2)} ${p.unit}, rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min, reaches ${p.threshold} ${p.unit} in about ${p.eta} of run time (fit quality r²=${p.r2.toFixed(2)}).`);
|
||||
}
|
||||
lines.push('These are straight-line extrapolations of the current trend, not guarantees. A change in load or a maintenance action will change them.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function workOrderAnswer(frame) {
|
||||
const a = frame.analytics;
|
||||
const worst = a.alarms[0];
|
||||
const pred = a.predictions[0];
|
||||
const cnc = frame.stations.find((s) => s.id === 'CNC-02');
|
||||
|
||||
const target = worst ? worst.station : pred ? pred.station : 'LINE-1';
|
||||
const priority = worst
|
||||
? worst.severity === 'critical' ? 'P1 - immediate' : worst.severity === 'major' ? 'P2 - same shift' : 'P3 - planned'
|
||||
: 'P3 - planned';
|
||||
|
||||
const lines = [
|
||||
'DRAFT MAINTENANCE WORK ORDER',
|
||||
`Asset: ${target}${target === 'CNC-02' ? ' (CNC Machining Centre)' : ''}`,
|
||||
`Priority: ${priority}`,
|
||||
`Raised at: simulated run time ${formatDuration(frame.t)}`,
|
||||
'',
|
||||
'Observed condition:',
|
||||
];
|
||||
|
||||
if (worst) lines.push(`- ${worst.message}`);
|
||||
if (cnc && cnc.signals.vibration > 3) {
|
||||
lines.push(`- Bearing vibration ${cnc.signals.vibration.toFixed(2)} mm/s RMS (baseline approximately 1.6), spindle load ${cnc.signals.spindleLoad.toFixed(1)}%, coolant ${cnc.signals.coolantTemp.toFixed(1)} °C.`);
|
||||
}
|
||||
if (pred) {
|
||||
lines.push(`- ${pred.label} projected to reach ${pred.threshold} ${pred.unit} in about ${pred.eta} of run time at the current rate.`);
|
||||
}
|
||||
|
||||
lines.push('', 'Recommended action:');
|
||||
if (target === 'CNC-02') {
|
||||
lines.push('- Inspect spindle bearing; take a vibration spectrum to confirm the fault frequency before replacing.');
|
||||
lines.push('- Check tooling condition and replace if wear is contributing.');
|
||||
lines.push('- Verify coolant flow and temperature.');
|
||||
} else if (target === 'OVN-03') {
|
||||
lines.push('- Inspect zone 2 burner, igniter and gas train; burner duty is saturated, which points at lost heating capacity.');
|
||||
lines.push('- Verify zone 2 thermocouple against a reference before condemning the burner.');
|
||||
} else if (target === 'PKG-05') {
|
||||
lines.push('- Clear the film path and inspect the web for tearing; check tension control calibration.');
|
||||
} else {
|
||||
lines.push('- Investigate the alarm above and confirm against local instrumentation.');
|
||||
}
|
||||
|
||||
lines.push('', 'Production impact:');
|
||||
lines.push(`- OEE ${pct(frame.kpi.oee)} (A ${pct(frame.kpi.availability)} / P ${pct(frame.kpi.performance)} / Q ${pct(frame.kpi.quality)}), scrap ${pct(frame.kpi.scrapRate)}, output ${frame.kpi.throughputPerHour.toFixed(0)} units/h.`);
|
||||
lines.push('', 'Drafted by the plant copilot from live telemetry. Review before issuing.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function whatIfAnswer(frame, q) {
|
||||
const lower = q.toLowerCase();
|
||||
const k = frame.kpi;
|
||||
const lines = [];
|
||||
|
||||
if (lower.includes('speed') || lower.includes('faster') || lower.includes('rate')) {
|
||||
const cnc = frame.stations.find((s) => s.id === 'CNC-02');
|
||||
lines.push(
|
||||
`CNC-02 is the bottleneck at a 4.4 s cycle, so line output tracks it directly. Raising line speed shortens every cycle, but it also raises spindle load — currently ${cnc.signals.spindleLoad.toFixed(1)}% — and load rises roughly 22 points per 100% of added speed.`,
|
||||
);
|
||||
if (cnc.signals.vibration > 3) {
|
||||
lines.push(`With bearing vibration already at ${cnc.signals.vibration.toFixed(2)} mm/s, running faster would accelerate the degradation and increase rejects. I would not raise speed until the bearing is addressed.`);
|
||||
} else {
|
||||
lines.push(`Vibration is ${cnc.signals.vibration.toFixed(2)} mm/s and load has headroom, so a modest increase is likely to hold. Watch spindle load against its 85% warning limit and reject rate against 4%.`);
|
||||
}
|
||||
lines.push('Use the line speed control in the what-if panel to try it — the twin will show the actual response.');
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
if (lower.includes('setpoint') || lower.includes('temperature') || lower.includes('oven') || lower.includes('hotter') || lower.includes('cooler')) {
|
||||
const ovn = frame.stations.find((s) => s.id === 'OVN-03');
|
||||
lines.push(
|
||||
`Zone 2 is at ${ovn.signals.zone2Temp.toFixed(1)} °C against a ${ovn.signals.setpoint} °C setpoint with burner duty ${ovn.signals.burnerDuty.toFixed(0)}%. The oven responds with a first-order lag of roughly 50 s per zone, so a setpoint change takes a few minutes of run time to settle, not seconds.`,
|
||||
);
|
||||
lines.push('Reject rate rises once zone 2 deviates more than about 6 °C from setpoint in either direction, so the cure window is the constraint, not the absolute temperature.');
|
||||
lines.push('Change the setpoint in the what-if panel and watch the zone 2 trend chart to see the real response.');
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
lines.push(`I can reason about two levers directly: oven setpoint and line speed. Current state is OEE ${pct(k.oee)}, output ${k.throughputPerHour.toFixed(0)} units/h, scrap ${pct(k.scrapRate)}.`);
|
||||
lines.push('Ask about raising line speed or changing the oven setpoint, or make the change in the what-if panel and watch the twin respond.');
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
function overviewAnswer(frame) {
|
||||
const a = frame.analytics;
|
||||
const lines = [];
|
||||
const narrative = causalNarrative(frame);
|
||||
|
||||
if (a.alarms.length === 0 && narrative.length === 0) {
|
||||
lines.push(`The line is running clean. OEE ${pct(frame.kpi.oee)}, output ${frame.kpi.throughputPerHour.toFixed(0)} good units/hour, scrap ${pct(frame.kpi.scrapRate)}, no active alarms.`);
|
||||
lines.push('All five stations are within limits and no threshold crossings are projected.');
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
if (a.alarms.length) {
|
||||
const top = a.alarms.slice(0, 3);
|
||||
lines.push(`${a.alarms.length} active alarm${a.alarms.length > 1 ? 's' : ''}. Most severe: ${top.map((x) => `[${x.severity}] ${x.message}`).join(' ')}`);
|
||||
}
|
||||
lines.push(...narrative);
|
||||
lines.push(`OEE is ${pct(frame.kpi.oee)} (A ${pct(frame.kpi.availability)} / P ${pct(frame.kpi.performance)} / Q ${pct(frame.kpi.quality)}), output ${frame.kpi.throughputPerHour.toFixed(0)} units/h.`);
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a question to a templated answer.
|
||||
*
|
||||
* Returns { text, provider: 'fallback' }.
|
||||
*/
|
||||
export function answerFromRules(question, frame) {
|
||||
if (!frame) {
|
||||
return { text: 'No telemetry has arrived yet. Start the simulation and ask again.', provider: 'fallback' };
|
||||
}
|
||||
const q = (question || '').toLowerCase();
|
||||
|
||||
if (/work order|maintenance order|raise a ticket|wo\b|cmms/.test(q)) return { text: workOrderAnswer(frame), provider: 'fallback' };
|
||||
if (/what happens if|what if|should i|raise|increase|decrease|lower|try /.test(q)) return { text: whatIfAnswer(frame, question), provider: 'fallback' };
|
||||
if (/oee|availability|performance|quality|efficiency|throughput|scrap/.test(q)) return { text: oeeAnswer(frame), provider: 'fallback' };
|
||||
if (/predict|forecast|when will|how long|fail|remaining life|rul/.test(q)) return { text: predictionAnswer(frame), provider: 'fallback' };
|
||||
|
||||
const station = resolveStation(q);
|
||||
if (station) return { text: stationAnswer(frame, station), provider: 'fallback' };
|
||||
|
||||
return { text: overviewAnswer(frame), provider: 'fallback' };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Runtime copilot settings.
|
||||
*
|
||||
* Precedence: saved settings file > environment > built-in defaults.
|
||||
*
|
||||
* Settings are persisted to a small JSON file rather than held only in memory, so
|
||||
* a model chosen while preparing a demo survives a server restart. They are
|
||||
* deliberately NOT written back into .env - that file holds the API key, and a
|
||||
* process that rewrites its own secrets file is a bad habit to build.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SETTINGS_FILE = path.join(HERE, '..', '..', '.copilot-settings.json');
|
||||
|
||||
/** Fields a client is allowed to change, with validation for each. */
|
||||
const FIELDS = {
|
||||
provider: {
|
||||
validate: (v) => ['auto', 'openrouter', 'ollama', 'fallback'].includes(v),
|
||||
error: 'provider must be auto, openrouter, ollama or fallback',
|
||||
},
|
||||
openRouterModel: {
|
||||
validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200,
|
||||
error: 'openRouterModel must be a non-empty model id',
|
||||
},
|
||||
ollamaModel: {
|
||||
validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200,
|
||||
error: 'ollamaModel must be a non-empty model id',
|
||||
},
|
||||
ollamaHost: {
|
||||
validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 300,
|
||||
error: 'ollamaHost must be a URL or host:port',
|
||||
},
|
||||
temperature: {
|
||||
validate: (v) => Number.isFinite(v) && v >= 0 && v <= 2,
|
||||
error: 'temperature must be between 0 and 2',
|
||||
},
|
||||
maxTokens: {
|
||||
validate: (v) => Number.isInteger(v) && v >= 128 && v <= 8000,
|
||||
error: 'maxTokens must be an integer between 128 and 8000',
|
||||
},
|
||||
reasoningEffort: {
|
||||
validate: (v) => ['none', 'low', 'medium', 'high'].includes(v),
|
||||
error: 'reasoningEffort must be none, low, medium or high',
|
||||
},
|
||||
};
|
||||
|
||||
export function defaultsFromEnv(env = process.env) {
|
||||
return {
|
||||
// 'auto' resolves by availability at detect() time.
|
||||
provider: (env.COPILOT_PROVIDER || 'auto').trim().toLowerCase(),
|
||||
openRouterModel: (env.OPENROUTER_MODEL || 'meta-llama/llama-3.3-70b-instruct').trim(),
|
||||
ollamaModel: (env.OLLAMA_MODEL || 'qwen3.5-4b-32k:latest').trim(),
|
||||
ollamaHost: (env.OLLAMA_HOST || 'http://127.0.0.1:11434').trim(),
|
||||
temperature: 0.3,
|
||||
maxTokens: 2000,
|
||||
// Reasoning tokens count against the output budget on most providers, so
|
||||
// 'low' keeps answers from being swallowed by a reasoning block and keeps
|
||||
// time-to-first-token usable in a live demo.
|
||||
reasoningEffort: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSettings(env = process.env) {
|
||||
const base = defaultsFromEnv(env);
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||
for (const key of Object.keys(FIELDS)) {
|
||||
if (raw[key] !== undefined && FIELDS[key].validate(raw[key])) base[key] = raw[key];
|
||||
}
|
||||
} catch {
|
||||
/* no saved settings yet, or unreadable - environment defaults stand */
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a patch. Returns { ok, errors, clean }.
|
||||
*
|
||||
* Unknown keys are rejected rather than ignored, so a typo in a field name fails
|
||||
* loudly instead of silently doing nothing.
|
||||
*/
|
||||
export function validatePatch(patch) {
|
||||
const errors = [];
|
||||
const clean = {};
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
const field = FIELDS[key];
|
||||
if (!field) { errors.push(`unknown setting "${key}"`); continue; }
|
||||
if (!field.validate(value)) { errors.push(field.error); continue; }
|
||||
clean[key] = value;
|
||||
}
|
||||
return { ok: errors.length === 0, errors, clean };
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
const out = {};
|
||||
for (const key of Object.keys(FIELDS)) {
|
||||
if (settings[key] !== undefined) out[key] = settings[key];
|
||||
}
|
||||
fs.writeFileSync(SETTINGS_FILE, `${JSON.stringify(out, null, 2)}\n`, 'utf8');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resetSettings() {
|
||||
try {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
} catch {
|
||||
/* nothing saved - already at defaults */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* The analytics layer: thresholds, anomalies, trend projections, alarm latching.
|
||||
*
|
||||
* Alarms are latched with hysteresis. Without it, a noisy signal sitting on a
|
||||
* threshold produces a flickering alarm list, which on a projector reads as a
|
||||
* broken product. A condition must hold for RAISE_AFTER seconds before it is
|
||||
* raised and clear for CLEAR_AFTER seconds before it is dropped.
|
||||
*/
|
||||
|
||||
import { STATION_SPECS, STATE } from '../sim/stations.js';
|
||||
import { TrendTracker, formatDuration } from './trend.js';
|
||||
import { BaselineBank } from './anomaly.js';
|
||||
|
||||
const RAISE_AFTER = 3;
|
||||
const CLEAR_AFTER = 15;
|
||||
|
||||
/** Sigma from the learned baseline before a signal is called anomalous. */
|
||||
const ANOMALY_Z = 4.5;
|
||||
|
||||
/** Only project a threshold crossing this far ahead, in simulated seconds. */
|
||||
const PREDICTION_HORIZON = 7200;
|
||||
|
||||
export const SEVERITY_RANK = { critical: 0, major: 1, warning: 2, predictive: 3, info: 4 };
|
||||
|
||||
/** Signals worth fitting a trend to: they drift, and they have a threshold. */
|
||||
function trendableSignals() {
|
||||
const out = [];
|
||||
for (const spec of STATION_SPECS) {
|
||||
for (const g of spec.signals) {
|
||||
const hasHigh = g.warnHigh !== undefined || g.alarmHigh !== undefined;
|
||||
if (!hasHigh) continue;
|
||||
if (g.key === 'motorAmps' || g.key === 'burnerDuty') continue; // state-driven, not drift
|
||||
out.push({ stationId: spec.id, signal: g });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class AnalyticsEngine {
|
||||
constructor() {
|
||||
this.trends = new Map();
|
||||
this.baselines = new BaselineBank({ warmupSec: 240 });
|
||||
this.candidates = new Map();
|
||||
this.active = new Map();
|
||||
this.trendable = trendableSignals();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.trends.clear();
|
||||
this.baselines.reset();
|
||||
this.candidates.clear();
|
||||
this.active.clear();
|
||||
}
|
||||
|
||||
trend(stationId, key) {
|
||||
const k = `${stationId}.${key}`;
|
||||
let tr = this.trends.get(k);
|
||||
if (!tr) {
|
||||
tr = new TrendTracker();
|
||||
this.trends.set(k, tr);
|
||||
}
|
||||
return tr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one snapshot into the analytics state and return the analytics block.
|
||||
*/
|
||||
update(snap) {
|
||||
const t = snap.t;
|
||||
const stationById = {};
|
||||
for (const st of snap.stations) stationById[st.id] = st;
|
||||
|
||||
// A station in a known-abnormal state must not teach the baseline.
|
||||
const lineClean = snap.faults.length === 0;
|
||||
|
||||
for (const spec of STATION_SPECS) {
|
||||
const st = stationById[spec.id];
|
||||
if (!st || !st.online) continue;
|
||||
const stationClean = lineClean && st.state !== STATE.DOWN && st.state !== STATE.FAULT;
|
||||
for (const g of spec.signals) {
|
||||
const v = st.signals[g.key];
|
||||
this.baselines.update(spec.id, g.key, t, v, stationClean);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { stationId, signal } of this.trendable) {
|
||||
const st = stationById[stationId];
|
||||
if (!st || !st.online) continue;
|
||||
// Only fit while the station is actually producing, so stall dips do not
|
||||
// flatten or corrupt the slope.
|
||||
if (st.state !== STATE.RUNNING) continue;
|
||||
this.trend(stationId, signal.key).update(t, st.signals[signal.key]);
|
||||
}
|
||||
|
||||
const found = [];
|
||||
this.collectStateAlarms(snap, found);
|
||||
this.collectThresholdAlarms(snap, stationById, found);
|
||||
this.collectAnomalyAlarms(snap, stationById, found);
|
||||
const predictions = this.collectPredictions(snap, stationById, found);
|
||||
|
||||
const alarms = this.latch(t, found);
|
||||
|
||||
return {
|
||||
alarms,
|
||||
predictions,
|
||||
trends: this.trendSummary(stationById),
|
||||
baselineReady: [...this.baselines.trackers.values()].some((b) => b.ready),
|
||||
};
|
||||
}
|
||||
|
||||
collectStateAlarms(snap, out) {
|
||||
for (const st of snap.stations) {
|
||||
if (!st.online) {
|
||||
out.push({
|
||||
key: `offline:${st.id}`, kind: 'data-quality', severity: 'major',
|
||||
station: st.id, signal: null,
|
||||
message: `${st.id} is not reporting. Values shown are the last known reading.`,
|
||||
});
|
||||
}
|
||||
if (st.state === STATE.FAULT) {
|
||||
const f = snap.faults.find((x) => x.station === st.id);
|
||||
out.push({
|
||||
key: `fault:${st.id}`, kind: 'stoppage', severity: 'critical',
|
||||
station: st.id, signal: null,
|
||||
message: f ? `${st.id} stopped: ${f.label}.` : `${st.id} stopped on fault.`,
|
||||
});
|
||||
} else if (st.state === STATE.DOWN) {
|
||||
out.push({
|
||||
key: `down:${st.id}`, kind: 'stoppage', severity: 'major',
|
||||
station: st.id, signal: null,
|
||||
message: `${st.id} unplanned stop.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectThresholdAlarms(snap, stationById, out) {
|
||||
for (const spec of STATION_SPECS) {
|
||||
const st = stationById[spec.id];
|
||||
if (!st || !st.online) continue;
|
||||
// A stopped station drops many signals to zero by design. Alarming on that
|
||||
// duplicates the stoppage alarm and buries the real cause.
|
||||
const stopped = st.state === STATE.FAULT || st.state === STATE.DOWN || st.state === STATE.MICROSTOP;
|
||||
|
||||
for (const g of spec.signals) {
|
||||
const v = st.signals[g.key];
|
||||
if (!Number.isFinite(v)) continue;
|
||||
let severity = null, bound = null, dir = null;
|
||||
|
||||
if (g.alarmHigh !== undefined && v >= g.alarmHigh) { severity = 'major'; bound = g.alarmHigh; dir = 'above'; }
|
||||
else if (g.warnHigh !== undefined && v >= g.warnHigh) { severity = 'warning'; bound = g.warnHigh; dir = 'above'; }
|
||||
else if (!stopped && g.alarmLow !== undefined && v <= g.alarmLow) { severity = 'major'; bound = g.alarmLow; dir = 'below'; }
|
||||
else if (!stopped && g.warnLow !== undefined && v <= g.warnLow) { severity = 'warning'; bound = g.warnLow; dir = 'below'; }
|
||||
|
||||
if (!severity) continue;
|
||||
out.push({
|
||||
key: `thresh:${spec.id}.${g.key}`, kind: 'threshold', severity,
|
||||
station: spec.id, signal: g.key,
|
||||
value: v, threshold: bound,
|
||||
message: `${g.label} ${v.toFixed(g.precision)} ${g.unit} is ${dir} the ${severity === 'major' ? 'alarm' : 'warning'} limit of ${bound} ${g.unit}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectAnomalyAlarms(snap, stationById, out) {
|
||||
const already = new Set(out.filter((a) => a.signal).map((a) => `${a.station}.${a.signal}`));
|
||||
for (const spec of STATION_SPECS) {
|
||||
const st = stationById[spec.id];
|
||||
if (!st || !st.online) continue;
|
||||
if (st.state !== STATE.RUNNING) continue;
|
||||
|
||||
for (const g of spec.signals) {
|
||||
if (already.has(`${spec.id}.${g.key}`)) continue;
|
||||
const v = st.signals[g.key];
|
||||
const z = this.baselines.z(spec.id, g.key, v);
|
||||
if (z === null || Math.abs(z) < ANOMALY_Z) continue;
|
||||
|
||||
// Only alarm in the direction that is actually bad, inferred from which
|
||||
// thresholds the signal declares. Otherwise a tool change (wear drops
|
||||
// from 18% to 2%) or a genuine quality improvement raises an alarm for
|
||||
// being *better* than baseline, which trains operators to ignore alarms.
|
||||
const badHigh = g.warnHigh !== undefined || g.alarmHigh !== undefined;
|
||||
const badLow = g.warnLow !== undefined || g.alarmLow !== undefined;
|
||||
if (z > 0 && badLow && !badHigh) continue;
|
||||
if (z < 0 && badHigh && !badLow) continue;
|
||||
const tr = this.baselines.get(spec.id, g.key);
|
||||
out.push({
|
||||
key: `anom:${spec.id}.${g.key}`, kind: 'anomaly', severity: 'warning',
|
||||
station: spec.id, signal: g.key,
|
||||
value: v, z,
|
||||
message: `${g.label} is ${Math.abs(z).toFixed(1)} sigma ${z > 0 ? 'above' : 'below'} its learned baseline of ${tr.mean.toFixed(g.precision)} ${g.unit}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectPredictions(snap, stationById, out) {
|
||||
const predictions = [];
|
||||
for (const { stationId, signal } of this.trendable) {
|
||||
const st = stationById[stationId];
|
||||
if (!st || !st.online) continue;
|
||||
|
||||
const limit = signal.alarmHigh ?? signal.warnHigh;
|
||||
if (limit === undefined) continue;
|
||||
const v = st.signals[signal.key];
|
||||
if (v >= limit) continue; // already there, no projection needed
|
||||
|
||||
const proj = this.trend(stationId, signal.key).timeToThreshold(limit);
|
||||
if (!proj || proj.seconds > PREDICTION_HORIZON) continue;
|
||||
|
||||
const p = {
|
||||
station: stationId,
|
||||
signal: signal.key,
|
||||
label: signal.label,
|
||||
unit: signal.unit,
|
||||
current: v,
|
||||
threshold: limit,
|
||||
seconds: proj.seconds,
|
||||
eta: formatDuration(proj.seconds),
|
||||
slopePerMin: proj.fit.slope * 60,
|
||||
r2: proj.fit.r2,
|
||||
windowSec: proj.fit.spanSec,
|
||||
};
|
||||
predictions.push(p);
|
||||
|
||||
out.push({
|
||||
key: `pred:${stationId}.${signal.key}`, kind: 'prediction', severity: 'predictive',
|
||||
station: stationId, signal: signal.key,
|
||||
value: v, threshold: limit, prediction: p,
|
||||
message: `${signal.label} trending up ${(proj.fit.slope * 60).toFixed(3)} ${signal.unit}/min. At this rate it reaches the ${limit} ${signal.unit} limit in about ${formatDuration(proj.seconds)} of run time.`,
|
||||
});
|
||||
}
|
||||
return predictions;
|
||||
}
|
||||
|
||||
/** Per-signal slope summary, used to give the copilot conclusions not raw floats. */
|
||||
trendSummary(stationById) {
|
||||
const out = [];
|
||||
for (const { stationId, signal } of this.trendable) {
|
||||
const f = this.trend(stationId, signal.key).fit();
|
||||
if (!f || f.r2 < 0.3) continue;
|
||||
const st = stationById[stationId];
|
||||
if (!st) continue;
|
||||
out.push({
|
||||
station: stationId,
|
||||
signal: signal.key,
|
||||
label: signal.label,
|
||||
unit: signal.unit,
|
||||
current: st.signals[signal.key],
|
||||
slopePerMin: f.slope * 60,
|
||||
r2: f.r2,
|
||||
windowSec: f.spanSec,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply raise/clear hysteresis and return the active alarm list.
|
||||
*/
|
||||
latch(t, found) {
|
||||
const foundByKey = new Map(found.map((a) => [a.key, a]));
|
||||
|
||||
for (const a of found) {
|
||||
const c = this.candidates.get(a.key);
|
||||
if (c) { c.lastSeen = t; c.payload = a; }
|
||||
else this.candidates.set(a.key, { firstSeen: t, lastSeen: t, payload: a });
|
||||
}
|
||||
|
||||
for (const [key, c] of this.candidates) {
|
||||
const stillPresent = foundByKey.has(key);
|
||||
const act = this.active.get(key);
|
||||
|
||||
if (stillPresent && !act && t - c.firstSeen >= RAISE_AFTER) {
|
||||
this.active.set(key, { ...c.payload, raisedAt: t });
|
||||
} else if (act) {
|
||||
if (stillPresent) {
|
||||
// Refresh the payload so values and messages stay live, keep raisedAt.
|
||||
this.active.set(key, { ...c.payload, raisedAt: act.raisedAt });
|
||||
} else if (t - c.lastSeen >= CLEAR_AFTER) {
|
||||
this.active.delete(key);
|
||||
this.candidates.delete(key);
|
||||
}
|
||||
} else if (!stillPresent && t - c.lastSeen >= CLEAR_AFTER) {
|
||||
this.candidates.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...this.active.values()].sort(
|
||||
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || b.raisedAt - a.raisedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Trend extrapolation.
|
||||
*
|
||||
* Ordinary least squares over a decimated window of recent samples. Chosen over
|
||||
* anything fancier for one reason: when the customer asks "how does it predict
|
||||
* that?", the answer is one sentence - a straight-line fit over the last N
|
||||
* minutes, projected to the alarm threshold, reported only when the fit is good
|
||||
* enough to mean something.
|
||||
*
|
||||
* This is deliberately NOT presented as AI. It is a regression.
|
||||
*/
|
||||
|
||||
export class TrendTracker {
|
||||
/**
|
||||
* windowSec - how much history to fit over, in simulated seconds
|
||||
* sampleEverySec- decimation interval, keeps the fit cheap and less noise-bound
|
||||
* minR2 - below this the fit is reported as unreliable, no projection
|
||||
*/
|
||||
constructor({ windowSec = 1200, sampleEverySec = 10, minR2 = 0.55 } = {}) {
|
||||
this.windowSec = windowSec;
|
||||
this.sampleEverySec = sampleEverySec;
|
||||
this.minR2 = minR2;
|
||||
this.samples = [];
|
||||
this.lastSampleT = -Infinity;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.samples = [];
|
||||
this.lastSampleT = -Infinity;
|
||||
}
|
||||
|
||||
update(t, value) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (t - this.lastSampleT < this.sampleEverySec) return;
|
||||
this.lastSampleT = t;
|
||||
this.samples.push({ t, v: value });
|
||||
const cutoff = t - this.windowSec;
|
||||
while (this.samples.length && this.samples[0].t < cutoff) this.samples.shift();
|
||||
}
|
||||
|
||||
/** Least-squares fit. slope is in units per simulated second. */
|
||||
fit() {
|
||||
const n = this.samples.length;
|
||||
if (n < 6) return null;
|
||||
|
||||
let st = 0, sv = 0;
|
||||
for (const s of this.samples) { st += s.t; sv += s.v; }
|
||||
const mt = st / n, mv = sv / n;
|
||||
|
||||
let stt = 0, stv = 0, svv = 0;
|
||||
for (const s of this.samples) {
|
||||
const dt = s.t - mt, dv = s.v - mv;
|
||||
stt += dt * dt; stv += dt * dv; svv += dv * dv;
|
||||
}
|
||||
if (stt === 0) return null;
|
||||
|
||||
const slope = stv / stt;
|
||||
const r2 = svv === 0 ? 0 : (stv * stv) / (stt * svv);
|
||||
return {
|
||||
slope,
|
||||
intercept: mv - slope * mt,
|
||||
mean: mv,
|
||||
n,
|
||||
r2,
|
||||
spanSec: this.samples[n - 1].t - this.samples[0].t,
|
||||
latest: this.samples[n - 1].v,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulated seconds until the fitted line reaches `threshold`.
|
||||
*
|
||||
* Returns null when there is no usable projection: too little data, a fit too
|
||||
* poor to trust, movement in the wrong direction, or the threshold already
|
||||
* crossed. Returning null is the honest answer and the UI shows nothing.
|
||||
*/
|
||||
timeToThreshold(threshold) {
|
||||
const f = this.fit();
|
||||
if (!f) return null;
|
||||
if (f.r2 < this.minR2) return null;
|
||||
|
||||
const current = f.latest;
|
||||
const rising = threshold > current;
|
||||
if (rising && f.slope <= 1e-9) return null;
|
||||
if (!rising && f.slope >= -1e-9) return null;
|
||||
|
||||
const secs = (threshold - current) / f.slope;
|
||||
if (!Number.isFinite(secs) || secs <= 0) return null;
|
||||
return { seconds: secs, fit: f };
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable duration for a span of simulated seconds. */
|
||||
export function formatDuration(sec) {
|
||||
if (!Number.isFinite(sec)) return 'unknown';
|
||||
if (sec < 90) return `${Math.round(sec)} s`;
|
||||
if (sec < 5400) return `${(sec / 60).toFixed(1)} min`;
|
||||
if (sec < 172800) return `${(sec / 3600).toFixed(1)} h`;
|
||||
return `${(sec / 86400).toFixed(1)} d`;
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Digital twin demo server.
|
||||
*
|
||||
* Owns the telemetry source, broadcasts frames over WebSocket at 2 Hz, exposes a
|
||||
* small control API for the what-if panel, and proxies the copilot so the API key
|
||||
* never reaches the browser.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import dotenv from 'dotenv';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
// Load .env from the repo root explicitly. `dotenv/config` resolves relative to
|
||||
// process.cwd(), and `npm run dev -w server` sets cwd to server/, so the root
|
||||
// .env was silently ignored - the symptom being a copilot that quietly falls back
|
||||
// to a different provider than the one you configured.
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.join(HERE, '..', '.env') });
|
||||
dotenv.config({ path: path.join(HERE, '.env') });
|
||||
|
||||
import { STATION_SPECS, BUFFER_CAPACITY } from './sim/stations.js';
|
||||
import { FAULT_PROFILES } from './sim/faults.js';
|
||||
import { KPI_WINDOW_SEC } from './sim/kpi.js';
|
||||
import { SimulatedSource, SPEEDS } from './ingest/simulatedSource.js';
|
||||
import { MqttSource } from './ingest/mqttSource.js';
|
||||
import { OpcUaSource } from './ingest/opcuaSource.js';
|
||||
import { Copilot } from './ai/copilot.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const SOURCE_KIND = (process.env.TELEMETRY_SOURCE || 'simulated').toLowerCase();
|
||||
|
||||
function createSource() {
|
||||
switch (SOURCE_KIND) {
|
||||
case 'mqtt':
|
||||
return new MqttSource({
|
||||
url: process.env.MQTT_URL,
|
||||
username: process.env.MQTT_USERNAME,
|
||||
password: process.env.MQTT_PASSWORD,
|
||||
});
|
||||
case 'opcua':
|
||||
return new OpcUaSource({
|
||||
endpoint: process.env.OPCUA_ENDPOINT,
|
||||
username: process.env.OPCUA_USERNAME,
|
||||
password: process.env.OPCUA_PASSWORD,
|
||||
});
|
||||
case 'simulated':
|
||||
return new SimulatedSource({
|
||||
seed: process.env.SIM_SEED ? Number(process.env.SIM_SEED) : undefined,
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unknown TELEMETRY_SOURCE "${SOURCE_KIND}". Use simulated, mqtt or opcua.`);
|
||||
}
|
||||
}
|
||||
|
||||
const source = createSource();
|
||||
const copilot = new Copilot();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
// --- static metadata the UI needs once, not on every frame -------------------
|
||||
|
||||
app.get('/api/meta', (_req, res) => {
|
||||
res.json({
|
||||
lineId: 'LINE-1',
|
||||
stations: STATION_SPECS,
|
||||
faults: FAULT_PROFILES,
|
||||
bufferCapacity: BUFFER_CAPACITY,
|
||||
kpiWindowSec: KPI_WINDOW_SEC,
|
||||
speeds: SPEEDS,
|
||||
source: { kind: source.name, capabilities: source.capabilities },
|
||||
copilot: copilot.status(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
source: source.name,
|
||||
hasFrame: Boolean(source.latest),
|
||||
simTime: source.latest ? source.latest.t : 0,
|
||||
copilot: copilot.status(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/state', (_req, res) => {
|
||||
if (!source.latest) return res.status(503).json({ error: 'No telemetry yet.' });
|
||||
res.json(source.latest);
|
||||
});
|
||||
|
||||
// --- control surface --------------------------------------------------------
|
||||
|
||||
/** Wrap a control action so an unsupported source returns 400, not a 500. */
|
||||
function control(handler) {
|
||||
return (req, res) => {
|
||||
try {
|
||||
const result = handler(req);
|
||||
res.json({ ok: true, result });
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/control/speed', control((req) => source.setSpeed(req.body.speed)));
|
||||
app.post('/api/control/setpoint', control((req) => source.setSetpoint(req.body.value)));
|
||||
app.post('/api/control/line-speed', control((req) => source.setLineSpeed(req.body.value)));
|
||||
app.post('/api/control/tool-change', control(() => source.toolChange()));
|
||||
app.post('/api/control/reset', control(() => source.reset()));
|
||||
|
||||
app.post('/api/control/fault', control((req) => {
|
||||
const { id, action } = req.body;
|
||||
if (!FAULT_PROFILES.some((f) => f.id === id)) throw new Error(`Unknown fault "${id}".`);
|
||||
return action === 'clear' ? source.clearFault(id) : source.injectFault(id);
|
||||
}));
|
||||
|
||||
// --- copilot ---------------------------------------------------------------
|
||||
|
||||
app.get('/api/copilot/status', async (_req, res) => {
|
||||
res.json(await copilot.detect());
|
||||
});
|
||||
|
||||
// --- settings ---------------------------------------------------------------
|
||||
|
||||
app.get('/api/settings', (_req, res) => {
|
||||
res.json({ settings: copilot.publicSettings(), status: copilot.status() });
|
||||
});
|
||||
|
||||
app.post('/api/settings', async (req, res) => {
|
||||
const result = await copilot.configure(req.body || {});
|
||||
// A rejected patch changes nothing, so report it as a client error.
|
||||
res.status(result.ok ? 200 : 400).json(result);
|
||||
});
|
||||
|
||||
app.post('/api/settings/reset', async (_req, res) => {
|
||||
res.json(await copilot.reset());
|
||||
});
|
||||
|
||||
app.get('/api/copilot/models', async (req, res) => {
|
||||
const provider = String(req.query.provider || '').toLowerCase();
|
||||
if (provider !== 'openrouter' && provider !== 'ollama') {
|
||||
return res.status(400).json({ error: 'provider must be openrouter or ollama' });
|
||||
}
|
||||
res.json(await copilot.listModels(provider, { force: req.query.force === '1' }));
|
||||
});
|
||||
|
||||
app.post('/api/copilot/test', async (req, res) => {
|
||||
const { provider, model } = req.body || {};
|
||||
res.json(await copilot.testProvider({ provider, model }));
|
||||
});
|
||||
|
||||
app.post('/api/copilot/chat', async (req, res) => {
|
||||
const { question, history, provider } = req.body || {};
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||||
|
||||
// Detect client disconnect on the RESPONSE, not the request. Express has
|
||||
// already consumed the POST body by this point, which ends the request stream
|
||||
// and fires req 'close' immediately - watching that aborts the stream before
|
||||
// the first token is ever written.
|
||||
let closed = false;
|
||||
res.on('close', () => { closed = true; });
|
||||
|
||||
try {
|
||||
for await (const chunk of copilot.stream(question, history, source.latest, provider)) {
|
||||
if (closed) break;
|
||||
send(chunk);
|
||||
}
|
||||
} catch (err) {
|
||||
// copilot.stream() degrades internally, so reaching here means something
|
||||
// unexpected broke. Still return prose rather than a broken stream.
|
||||
console.error('[copilot] unexpected failure:', err);
|
||||
if (!closed) send({ type: 'token', text: `The copilot failed unexpectedly: ${err.message}` });
|
||||
}
|
||||
if (!res.writableEnded) {
|
||||
send({ type: 'end' });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// --- built front end -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve web/dist when it exists, so `npm run build` plus this process is a
|
||||
* complete, single-port deployment with no dev server and no second origin.
|
||||
*
|
||||
* This is what makes the "one Node process, works with no internet" claim true.
|
||||
* In development Vite serves the front end on :5173 and proxies here instead, so
|
||||
* this block simply finds no dist directory and does nothing.
|
||||
*/
|
||||
const DIST = path.join(HERE, '..', 'web', 'dist');
|
||||
if (fs.existsSync(path.join(DIST, 'index.html'))) {
|
||||
app.use(express.static(DIST));
|
||||
|
||||
// SPA fallback for anything that is not an API call. Registered as middleware
|
||||
// rather than app.get('*') because Express 5 rejects a bare '*' path.
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next();
|
||||
res.sendFile(path.join(DIST, 'index.html'));
|
||||
});
|
||||
console.log(`[server] serving built front end from ${DIST}`);
|
||||
} else {
|
||||
console.log('[server] no web/dist found - run "npm run build" for single-port mode');
|
||||
}
|
||||
|
||||
// --- websocket broadcast ---------------------------------------------------
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
// Replay recent history first so the trend charts are populated on load, then
|
||||
// the current frame so the dashboard renders without waiting for the next tick.
|
||||
if (source.replay.length) {
|
||||
ws.send(JSON.stringify({ type: 'history', frames: source.replay }));
|
||||
}
|
||||
if (source.latest) {
|
||||
ws.send(JSON.stringify({ type: 'frame', frame: source.latest }));
|
||||
}
|
||||
ws.on('error', (err) => console.error('[ws] client error:', err.message));
|
||||
});
|
||||
|
||||
source.onFrame((frame) => {
|
||||
const msg = JSON.stringify({ type: 'frame', frame });
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === 1) client.send(msg);
|
||||
}
|
||||
});
|
||||
|
||||
// --- startup ---------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const status = await copilot.detect();
|
||||
console.log(`[copilot] ${status.label} - ${status.detail}`);
|
||||
|
||||
try {
|
||||
await source.start();
|
||||
console.log(`[source] ${source.name} started`);
|
||||
} catch (err) {
|
||||
// A stub source throws a long explanatory message. Print it and stop, rather
|
||||
// than serving a dashboard with no data behind it.
|
||||
console.error(`\n[source] failed to start "${SOURCE_KIND}":\n${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[server] http://localhost:${PORT} (ws://localhost:${PORT}/ws)`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(sig, async () => {
|
||||
await source.stop();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* MQTT telemetry source - DOCUMENTED STUB.
|
||||
*
|
||||
* This is not implemented, and it says so honestly rather than pretending. What
|
||||
* it does provide is the exact shape of the work: the tag map, the frame
|
||||
* assembly, and where analytics plugs in. Wiring this to a real broker is a
|
||||
* day's work, not a rewrite, because everything downstream consumes frames.
|
||||
*
|
||||
* To implement:
|
||||
* 1. npm i mqtt
|
||||
* 2. Fill TAG_MAP with the customer's actual topic names.
|
||||
* 3. Implement start() as marked below.
|
||||
* 4. Set TELEMETRY_SOURCE=mqtt and MQTT_URL in .env
|
||||
*
|
||||
* Sparkplug B note: most industrial brokers publish Sparkplug B protobuf rather
|
||||
* than plain JSON on flat topics. If so, add `sparkplug-payload` to decode
|
||||
* NBIRTH/NDATA messages and map metric aliases instead of topic strings.
|
||||
*/
|
||||
|
||||
import { TelemetrySource } from './source.js';
|
||||
import { AnalyticsEngine } from '../analytics/alarms.js';
|
||||
|
||||
/**
|
||||
* Maps a broker topic to a station signal.
|
||||
*
|
||||
* The demo model expects the signals declared in server/sim/stations.js. Any
|
||||
* topic not mapped here is ignored; any signal not supplied by the broker simply
|
||||
* has no data, and the UI shows it as such rather than inventing a value.
|
||||
*/
|
||||
export const TAG_MAP = {
|
||||
// 'plant/line1/conveyor01/belt_speed': { station: 'CONV-01', signal: 'beltSpeed' },
|
||||
// 'plant/line1/conveyor01/motor_current': { station: 'CONV-01', signal: 'motorAmps' },
|
||||
// 'plant/line1/cnc02/vibration_rms': { station: 'CNC-02', signal: 'vibration' },
|
||||
// 'plant/line1/cnc02/spindle_load': { station: 'CNC-02', signal: 'spindleLoad' },
|
||||
// 'plant/line1/oven03/zone2_pv': { station: 'OVN-03', signal: 'zone2Temp' },
|
||||
// 'plant/line1/oven03/zone2_sp': { station: 'OVN-03', signal: 'setpoint' },
|
||||
// 'plant/line1/ins04/reject_rate': { station: 'INS-04', signal: 'rejectRate' },
|
||||
// 'plant/line1/pkg05/units_per_min': { station: 'PKG-05', signal: 'unitsPerMin' },
|
||||
};
|
||||
|
||||
export class MqttSource extends TelemetrySource {
|
||||
constructor({ url, username, password, topicPrefix } = {}) {
|
||||
super('mqtt');
|
||||
this.url = url;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.topicPrefix = topicPrefix;
|
||||
this.analytics = new AnalyticsEngine();
|
||||
}
|
||||
|
||||
/** A real broker feed is read-only: you observe the plant, you do not drive it. */
|
||||
get capabilities() {
|
||||
return { timeControl: false, faultInjection: false, setpointControl: false };
|
||||
}
|
||||
|
||||
async start() {
|
||||
throw new Error(
|
||||
'MQTT source is not configured. This is a documented stub.\n' +
|
||||
'To enable it: npm i mqtt, populate TAG_MAP in server/ingest/mqttSource.js ' +
|
||||
'with your topic names, implement start(), then set TELEMETRY_SOURCE=mqtt ' +
|
||||
'and MQTT_URL in .env.\n' +
|
||||
'Run with TELEMETRY_SOURCE=simulated for the demo.',
|
||||
);
|
||||
|
||||
/* Implementation outline:
|
||||
*
|
||||
* const mqtt = await import('mqtt');
|
||||
* this.client = mqtt.connect(this.url, { username: this.username, password: this.password });
|
||||
* this.client.on('connect', () => this.client.subscribe(Object.keys(TAG_MAP)));
|
||||
*
|
||||
* // Accumulate the latest value per tag. Industrial tags publish on change,
|
||||
* // at wildly different rates, so you assemble a frame on a timer rather
|
||||
* // than trying to emit one per message.
|
||||
* this.client.on('message', (topic, payload) => {
|
||||
* const tag = TAG_MAP[topic];
|
||||
* if (!tag) return;
|
||||
* this.values[`${tag.station}.${tag.signal}`] = Number(payload.toString());
|
||||
* });
|
||||
*
|
||||
* this.timer = setInterval(() => this.assembleAndEmit(), 500);
|
||||
*
|
||||
* assembleAndEmit() builds the same frame shape SimulatedSource emits:
|
||||
* stations with their signals, KPI rollups (see server/sim/kpi.js - the
|
||||
* KpiTracker works on any counter source, not just the simulator), then
|
||||
* this.analytics.update(snapshot) and this.emit(frame).
|
||||
*
|
||||
* Two things that bite in the real world:
|
||||
* - Staleness. Track a per-tag last-seen timestamp and mark a station
|
||||
* offline when its tags go quiet, exactly as the F4 dropout fault does.
|
||||
* Never let a stale value render as if it were live.
|
||||
* - Units. Vibration in in/s, temperature in F, and pressure in psi are all
|
||||
* common. Convert at the boundary here, not downstream.
|
||||
*/
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.client) this.client.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* OPC-UA telemetry source - DOCUMENTED STUB.
|
||||
*
|
||||
* OPC-UA is usually the right answer when the customer already has a PLC or SCADA
|
||||
* layer, because it gives you a browsable address space, real subscriptions, and
|
||||
* server-side timestamps rather than a flat topic namespace.
|
||||
*
|
||||
* To implement:
|
||||
* 1. npm i node-opcua
|
||||
* 2. Fill NODE_MAP with the customer's actual NodeIds (browse the server first).
|
||||
* 3. Implement start() as marked below.
|
||||
* 4. Set TELEMETRY_SOURCE=opcua and OPCUA_ENDPOINT in .env
|
||||
*
|
||||
* For testing without a plant, Prosys OPC-UA Simulation Server or the
|
||||
* node-opcua sample server both work locally.
|
||||
*/
|
||||
|
||||
import { TelemetrySource } from './source.js';
|
||||
import { AnalyticsEngine } from '../analytics/alarms.js';
|
||||
|
||||
/**
|
||||
* Maps an OPC-UA NodeId to a station signal.
|
||||
*
|
||||
* NodeIds are namespace-qualified and installation-specific - never guess them.
|
||||
* Browse the server's address space and read them off.
|
||||
*/
|
||||
export const NODE_MAP = {
|
||||
// 'ns=2;s=Line1.CONV01.BeltSpeed': { station: 'CONV-01', signal: 'beltSpeed' },
|
||||
// 'ns=2;s=Line1.CNC02.VibrationRMS': { station: 'CNC-02', signal: 'vibration' },
|
||||
// 'ns=2;s=Line1.CNC02.SpindleLoad': { station: 'CNC-02', signal: 'spindleLoad' },
|
||||
// 'ns=2;s=Line1.OVN03.Zone2PV': { station: 'OVN-03', signal: 'zone2Temp' },
|
||||
// 'ns=2;s=Line1.OVN03.Zone2SP': { station: 'OVN-03', signal: 'setpoint' },
|
||||
// 'ns=2;s=Line1.INS04.RejectRate': { station: 'INS-04', signal: 'rejectRate' },
|
||||
// 'ns=2;s=Line1.PKG05.UnitsPerMin': { station: 'PKG-05', signal: 'unitsPerMin' },
|
||||
};
|
||||
|
||||
export class OpcUaSource extends TelemetrySource {
|
||||
constructor({ endpoint, securityMode, username, password } = {}) {
|
||||
super('opcua');
|
||||
this.endpoint = endpoint;
|
||||
this.securityMode = securityMode;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.analytics = new AnalyticsEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only by default, deliberately.
|
||||
*
|
||||
* OPC-UA can write back to a PLC, and a twin that can change a real setpoint is
|
||||
* a genuinely useful thing - but it is also a safety-critical action that needs
|
||||
* interlocks, an audit trail, and the customer's explicit sign-off. Do not turn
|
||||
* setpointControl on here because the demo UI has a slider.
|
||||
*/
|
||||
get capabilities() {
|
||||
return { timeControl: false, faultInjection: false, setpointControl: false };
|
||||
}
|
||||
|
||||
async start() {
|
||||
throw new Error(
|
||||
'OPC-UA source is not configured. This is a documented stub.\n' +
|
||||
'To enable it: npm i node-opcua, populate NODE_MAP in ' +
|
||||
'server/ingest/opcuaSource.js with NodeIds browsed from your server, ' +
|
||||
'implement start(), then set TELEMETRY_SOURCE=opcua and OPCUA_ENDPOINT in .env.\n' +
|
||||
'Run with TELEMETRY_SOURCE=simulated for the demo.',
|
||||
);
|
||||
|
||||
/* Implementation outline:
|
||||
*
|
||||
* const { OPCUAClient, MessageSecurityMode, SecurityPolicy, AttributeIds,
|
||||
* ClientSubscription, TimestampsToReturn } = await import('node-opcua');
|
||||
*
|
||||
* this.client = OPCUAClient.create({ endpointMustExist: false });
|
||||
* await this.client.connect(this.endpoint);
|
||||
* this.session = await this.client.createSession(
|
||||
* this.username ? { userName: this.username, password: this.password } : undefined);
|
||||
*
|
||||
* this.subscription = await this.session.createSubscription2({
|
||||
* requestedPublishingInterval: 500,
|
||||
* publishingEnabled: true,
|
||||
* });
|
||||
*
|
||||
* for (const [nodeId, tag] of Object.entries(NODE_MAP)) {
|
||||
* const item = await this.subscription.monitor(
|
||||
* { nodeId, attributeId: AttributeIds.Value },
|
||||
* { samplingInterval: 500, queueSize: 10, discardOldest: true },
|
||||
* TimestampsToReturn.Both);
|
||||
* item.on('changed', (dataValue) => {
|
||||
* // Honour the status code. A Bad or Uncertain value must NOT be
|
||||
* // rendered as live data - that is how a twin starts lying.
|
||||
* if (!dataValue.statusCode.isGood()) {
|
||||
* this.markStale(tag);
|
||||
* return;
|
||||
* }
|
||||
* this.values[`${tag.station}.${tag.signal}`] = {
|
||||
* value: dataValue.value.value,
|
||||
* // Prefer the SOURCE timestamp: it is when the PLC sampled the
|
||||
* // sensor, not when the message happened to reach us.
|
||||
* t: dataValue.sourceTimestamp ?? dataValue.serverTimestamp,
|
||||
* };
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* this.timer = setInterval(() => this.assembleAndEmit(), 500);
|
||||
*
|
||||
* assembleAndEmit() builds the same frame shape SimulatedSource emits, then
|
||||
* calls this.analytics.update(snapshot) and this.emit(frame). The analytics
|
||||
* layer needs no changes at all: TrendTracker and BaselineBank work on
|
||||
* timestamped values regardless of where they came from.
|
||||
*/
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.subscription) await this.subscription.terminate();
|
||||
if (this.session) await this.session.close();
|
||||
if (this.client) await this.client.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The telemetry ingest seam.
|
||||
*
|
||||
* Everything downstream of this interface - analytics, alarms, the API, the UI,
|
||||
* the copilot - only ever sees frames. It has no idea whether those frames came
|
||||
* from a simulator, an MQTT broker, or an OPC-UA server.
|
||||
*
|
||||
* That is the whole point of putting a seam here rather than running the
|
||||
* simulator in the browser: "can it take our data?" is answered by implementing
|
||||
* one class, not by rewriting the application.
|
||||
*
|
||||
* A frame is the ProductionLine snapshot plus the analytics block:
|
||||
* { t, wallT, lineId, stations[], buffers[], bufferCapacity, kpi, faults[],
|
||||
* controls, totals, analytics: { alarms[], predictions[], trends[] },
|
||||
* sim: { speed, paused, tickMs, source } }
|
||||
*/
|
||||
|
||||
/** Frames of history retained for replay to newly connected clients. */
|
||||
export const REPLAY_FRAMES = 240;
|
||||
|
||||
export class TelemetrySource {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.listeners = new Set();
|
||||
this.latest = null;
|
||||
/**
|
||||
* A short ring of recent frames, trimmed to what the charts need.
|
||||
*
|
||||
* Without this, opening the dashboard gives you empty trend charts that take
|
||||
* minutes of wall-clock to fill - so the first thing a customer sees is a
|
||||
* dashboard with no history on it. Replaying this on connect means the charts
|
||||
* are populated the instant the page loads.
|
||||
*/
|
||||
this.replay = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* What this source supports. The UI hides controls a source cannot honour, so
|
||||
* a read-only historian replay does not show fault-injection buttons that
|
||||
* would silently do nothing.
|
||||
*/
|
||||
get capabilities() {
|
||||
return { timeControl: false, faultInjection: false, setpointControl: false };
|
||||
}
|
||||
|
||||
onFrame(cb) {
|
||||
this.listeners.add(cb);
|
||||
return () => this.listeners.delete(cb);
|
||||
}
|
||||
|
||||
emit(frame) {
|
||||
this.latest = frame;
|
||||
|
||||
this.replay.push({
|
||||
t: frame.t,
|
||||
kpi: {
|
||||
oee: frame.kpi.oee,
|
||||
availability: frame.kpi.availability,
|
||||
performance: frame.kpi.performance,
|
||||
quality: frame.kpi.quality,
|
||||
},
|
||||
stations: frame.stations.map((s) => ({ id: s.id, online: s.online, signals: s.signals })),
|
||||
});
|
||||
if (this.replay.length > REPLAY_FRAMES) this.replay.shift();
|
||||
|
||||
for (const cb of this.listeners) {
|
||||
try {
|
||||
cb(frame);
|
||||
} catch (err) {
|
||||
console.error(`[${this.name}] frame listener failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
throw new Error(`${this.name}: start() not implemented`);
|
||||
}
|
||||
|
||||
async stop() {}
|
||||
|
||||
// --- optional control surface; sources that cannot do these should throw ---
|
||||
|
||||
setSpeed() { throw new Error(`${this.name} does not support time control`); }
|
||||
setSetpoint() { throw new Error(`${this.name} does not support setpoint control`); }
|
||||
setLineSpeed() { throw new Error(`${this.name} does not support line speed control`); }
|
||||
injectFault() { throw new Error(`${this.name} does not support fault injection`); }
|
||||
clearFault() { throw new Error(`${this.name} does not support fault injection`); }
|
||||
toolChange() { throw new Error(`${this.name} does not support maintenance actions`); }
|
||||
reset() { throw new Error(`${this.name} does not support reset`); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "digitaltwin-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.1",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Injectable fault profiles.
|
||||
*
|
||||
* Each profile turns "time since injection" into a set of physics modifiers that
|
||||
* stations.js and line.js consume. Faults are progressive where a real fault
|
||||
* would be progressive - the bearing does not fail the instant you press the
|
||||
* button, it degrades, which is the whole point of showing trend extrapolation.
|
||||
*/
|
||||
|
||||
export const FAULT_PROFILES = [
|
||||
{
|
||||
id: 'bearing-degradation',
|
||||
station: 'CNC-02',
|
||||
label: 'CNC-02 bearing degradation',
|
||||
short: 'Bearing wear',
|
||||
severity: 'major',
|
||||
headline: true,
|
||||
description:
|
||||
'Spindle bearing begins to spall. Vibration rises exponentially, spindle load creeps up, and out-of-tolerance parts start reaching inspection.',
|
||||
},
|
||||
{
|
||||
id: 'oven-burner',
|
||||
station: 'OVN-03',
|
||||
label: 'OVN-03 zone 2 burner fault',
|
||||
short: 'Burner fault',
|
||||
severity: 'major',
|
||||
description:
|
||||
'Zone 2 loses roughly 40% of its heating capacity. The controller saturates trying to hold setpoint, the cure runs cold, and quality falls downstream.',
|
||||
},
|
||||
{
|
||||
id: 'packer-jam',
|
||||
station: 'PKG-05',
|
||||
label: 'PKG-05 film jam',
|
||||
short: 'Film jam',
|
||||
severity: 'critical',
|
||||
description:
|
||||
'Film binds then tears, stopping the packer. Work in progress backs up through the line and upstream stations block.',
|
||||
},
|
||||
{
|
||||
id: 'sensor-dropout',
|
||||
station: 'INS-04',
|
||||
label: 'INS-04 sensor dropout',
|
||||
short: 'Sensor dropout',
|
||||
severity: 'minor',
|
||||
description:
|
||||
'The inspection station stops reporting. Values go stale rather than to zero, which is what a real dropout looks like and what a twin has to handle honestly.',
|
||||
},
|
||||
];
|
||||
|
||||
export function faultProfile(id) {
|
||||
return FAULT_PROFILES.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
/** Time constant for the bearing ramp, in simulated seconds. */
|
||||
const BEARING_TAU = 420;
|
||||
const BEARING_GAIN = 0.35;
|
||||
const BEARING_CAP = 3.2;
|
||||
|
||||
/**
|
||||
* Collapse the set of active faults into physics modifiers.
|
||||
*
|
||||
* active: Map of faultId -> { injectedAt } in simulated seconds.
|
||||
*/
|
||||
export function computeModifiers(active, simTime) {
|
||||
const mods = {
|
||||
bearingVibration: 0,
|
||||
ovenZone2Capacity: 1,
|
||||
packerJamPhase: null,
|
||||
faultedStations: new Set(),
|
||||
offlineStations: new Set(),
|
||||
};
|
||||
|
||||
for (const [id, info] of active) {
|
||||
const t = Math.max(0, simTime - info.injectedAt);
|
||||
|
||||
switch (id) {
|
||||
case 'bearing-degradation':
|
||||
// Exponential ramp: slow to start, unmistakable once it moves. Crosses
|
||||
// the 3.5 mm/s warn band around 13 simulated minutes.
|
||||
mods.bearingVibration = Math.min(
|
||||
BEARING_CAP,
|
||||
BEARING_GAIN * (Math.exp(t / BEARING_TAU) - 1),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'oven-burner': {
|
||||
// Capacity degrades over the first minute rather than cliff-edging.
|
||||
const frac = Math.min(1, t / 60);
|
||||
mods.ovenZone2Capacity = 1 - 0.38 * frac;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'packer-jam':
|
||||
mods.faultedStations.add('PKG-05');
|
||||
mods.packerJamPhase = t < 8 ? 'bind' : 'tear';
|
||||
break;
|
||||
|
||||
case 'sensor-dropout':
|
||||
mods.offlineStations.add('INS-04');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return mods;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* OEE and line KPI rollups.
|
||||
*
|
||||
* Everything is computed over a rolling window of simulated time so the numbers
|
||||
* actually move during a demo. A cumulative-since-reset OEE barely budges in
|
||||
* twenty minutes, which reads as a broken dashboard.
|
||||
*
|
||||
* OEE = Availability x Performance x Quality, using the standard definitions:
|
||||
* Availability = run time / planned production time
|
||||
* Performance = (total parts x ideal cycle time) / run time
|
||||
* Quality = good parts / total parts
|
||||
*/
|
||||
|
||||
/** Rolling window length, in simulated seconds. */
|
||||
export const KPI_WINDOW_SEC = 1200;
|
||||
|
||||
export class KpiTracker {
|
||||
constructor(idealCycleTime) {
|
||||
this.idealCycleTime = idealCycleTime;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
/** Ring of cumulative counters, so any window is a difference of two samples. */
|
||||
this.samples = [];
|
||||
this.cum = { plannedSec: 0, runSec: 0, total: 0, good: 0, rejected: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate one simulation sub-step.
|
||||
*
|
||||
* lineUp: false while any station is in a fault state (planned time still
|
||||
* accrues, run time does not - that is what Availability measures).
|
||||
*/
|
||||
accumulate(dt, lineUp, producedTotal, producedGood, producedRejected) {
|
||||
this.cum.plannedSec += dt;
|
||||
if (lineUp) this.cum.runSec += dt;
|
||||
this.cum.total = producedTotal;
|
||||
this.cum.good = producedGood;
|
||||
this.cum.rejected = producedRejected;
|
||||
}
|
||||
|
||||
/** Record a window sample. Call once per broadcast tick, not per sub-step. */
|
||||
mark(simTime) {
|
||||
this.samples.push({ t: simTime, ...this.cum });
|
||||
while (this.samples.length > 2 && simTime - this.samples[0].t > KPI_WINDOW_SEC) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute KPIs over the rolling window. */
|
||||
compute(stations, instantPowerKw) {
|
||||
const first = this.samples[0];
|
||||
const last = this.samples[this.samples.length - 1];
|
||||
|
||||
if (!first || !last || last.t - first.t < 1) {
|
||||
return {
|
||||
oee: 0, availability: 0, performance: 0, quality: 0,
|
||||
throughputPerHour: 0, scrapRate: 0, energyKw: instantPowerKw,
|
||||
energyPerUnit: 0, windowSec: 0,
|
||||
produced: this.cum.total, good: this.cum.good, rejected: this.cum.rejected,
|
||||
};
|
||||
}
|
||||
|
||||
const dPlanned = last.plannedSec - first.plannedSec;
|
||||
const dRun = last.runSec - first.runSec;
|
||||
const dTotal = last.total - first.total;
|
||||
const dGood = last.good - first.good;
|
||||
const dRejected = last.rejected - first.rejected;
|
||||
|
||||
const availability = dPlanned > 0 ? dRun / dPlanned : 0;
|
||||
|
||||
// Performance is capped at 100% by definition: the ideal cycle time is the
|
||||
// fastest the line can physically go, so exceeding it is impossible. Draining
|
||||
// a WIP buffer can briefly produce faster than the bottleneck, which would
|
||||
// otherwise show as OEE above 100% and read as a broken dashboard.
|
||||
const performance = dRun > 0 ? Math.min(1, (dTotal * this.idealCycleTime) / dRun) : 0;
|
||||
const quality = dTotal > 0 ? dGood / dTotal : dRun > 0 ? 1 : 0;
|
||||
const oee = availability * performance * quality;
|
||||
|
||||
const hours = dPlanned / 3600;
|
||||
const throughputPerHour = hours > 0 ? dGood / hours : 0;
|
||||
|
||||
return {
|
||||
oee,
|
||||
availability,
|
||||
performance,
|
||||
quality,
|
||||
throughputPerHour,
|
||||
scrapRate: dTotal > 0 ? dRejected / dTotal : 0,
|
||||
energyKw: instantPowerKw,
|
||||
energyPerUnit: throughputPerHour > 0 ? instantPowerKw / throughputPerHour : 0,
|
||||
windowSec: dPlanned,
|
||||
produced: this.cum.total,
|
||||
good: this.cum.good,
|
||||
rejected: this.cum.rejected,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Instantaneous line power draw, kW. */
|
||||
export function instantPower(stations) {
|
||||
let kw = 0;
|
||||
for (const st of stations) {
|
||||
switch (st.kind) {
|
||||
case 'conveyor':
|
||||
kw += st.signals.motorAmps * 0.62;
|
||||
break;
|
||||
case 'cnc':
|
||||
kw += (st.signals.spindleLoad / 100) * st.power;
|
||||
break;
|
||||
case 'oven':
|
||||
// An oven keeps drawing standby heat even when the line is stopped.
|
||||
kw += (st.signals.burnerDuty / 100) * st.power + 6;
|
||||
break;
|
||||
default:
|
||||
kw += st.state === 'running' ? st.power : st.power * 0.2;
|
||||
}
|
||||
}
|
||||
return kw;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* The production line model: discrete part flow over continuous signal physics.
|
||||
*
|
||||
* Stations are connected by finite WIP buffers, so they block and starve each
|
||||
* other. That coupling is what makes the model read as a plant rather than as
|
||||
* five unrelated gauges - stop the packer and the backup propagates upstream
|
||||
* until the whole line is blocked.
|
||||
*
|
||||
* Stations are stepped downstream-first so that a block resolves within a single
|
||||
* sub-step rather than crawling one station per tick.
|
||||
*/
|
||||
|
||||
import {
|
||||
STATION_SPECS, STATE, BUFFER_CAPACITY, DOWNTIME_STATES,
|
||||
createStation, seedStation, updateSignals, makeRng, clamp,
|
||||
} from './stations.js';
|
||||
import { computeModifiers, faultProfile } from './faults.js';
|
||||
import { KpiTracker, instantPower } from './kpi.js';
|
||||
|
||||
/** Largest sub-step we will integrate, in simulated seconds. */
|
||||
export const MAX_SUBSTEP = 0.5;
|
||||
|
||||
const INSPECTION_INDEX = STATION_SPECS.findIndex((s) => s.kind === 'inspection');
|
||||
const IDEAL_CYCLE = Math.max(...STATION_SPECS.map((s) => s.baseCycleTime));
|
||||
|
||||
/**
|
||||
* Background loss rates, per station, per simulated second.
|
||||
*
|
||||
* Without these the line runs at ~97% OEE, which no plant manager will believe.
|
||||
* Micro-stops (a jammed part, a sensor re-read, an operator intervention) are the
|
||||
* single largest OEE loss in most real factories, and unplanned stops are what
|
||||
* Availability actually measures. Modelling them is more honest than hard-coding
|
||||
* a plausible-looking OEE number.
|
||||
*/
|
||||
const MICRO_STOP_RATE = 1 / 110;
|
||||
const MICRO_STOP_MIN = 4;
|
||||
const MICRO_STOP_SPAN = 12;
|
||||
const UNPLANNED_STOP_RATE = 1 / 20000;
|
||||
const UNPLANNED_STOP_MIN = 25;
|
||||
const UNPLANNED_STOP_SPAN = 55;
|
||||
|
||||
export class ProductionLine {
|
||||
constructor(seed = 0x5eed) {
|
||||
this.seed = seed;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
// Two independent streams. Signal noise is drawn every sub-step in a fixed
|
||||
// pattern, so sharing one stream with discrete event decisions (reject rolls,
|
||||
// stall decisions) lands those decisions at a correlated phase in the
|
||||
// sequence and measurably biases them - a reject roll against a 2.0% rate was
|
||||
// firing at 5.7%. Keep event randomness on its own stream.
|
||||
this.rng = makeRng(this.seed);
|
||||
this.stallRng = makeRng(this.seed ^ 0x9e3779b9);
|
||||
this.qualityRng = makeRng(this.seed ^ 0x85ebca6b);
|
||||
this.simTime = 0;
|
||||
this.stations = STATION_SPECS.map(createStation);
|
||||
this.stationById = {};
|
||||
for (const st of this.stations) {
|
||||
seedStation(st);
|
||||
this.stationById[st.id] = st;
|
||||
}
|
||||
this.buffers = new Array(this.stations.length - 1).fill(4);
|
||||
this.activeFaults = new Map();
|
||||
this.controls = { setpoint: 305, lineSpeedPct: 100 };
|
||||
this.totals = { produced: 0, good: 0, rejected: 0 };
|
||||
this.completionTimes = [];
|
||||
this.events = [];
|
||||
this.kpi = new KpiTracker(IDEAL_CYCLE);
|
||||
this.stationById['OVN-03'].signals.setpoint = this.controls.setpoint;
|
||||
this.logEvent('info', 'system', 'Line reset. Running at nominal setpoints.');
|
||||
}
|
||||
|
||||
// -- operator actions -----------------------------------------------------
|
||||
|
||||
setSetpoint(v) {
|
||||
const value = clamp(Number(v), 200, 360);
|
||||
this.controls.setpoint = value;
|
||||
this.stationById['OVN-03'].signals.setpoint = value;
|
||||
this.logEvent('action', 'OVN-03', `Oven setpoint changed to ${value.toFixed(0)} °C.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
setLineSpeed(pct) {
|
||||
const value = clamp(Number(pct), 50, 130);
|
||||
this.controls.lineSpeedPct = value;
|
||||
this.logEvent('action', 'LINE-1', `Line speed set to ${value.toFixed(0)}%.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
injectFault(id) {
|
||||
const profile = faultProfile(id);
|
||||
if (!profile) return false;
|
||||
if (this.activeFaults.has(id)) return true;
|
||||
this.activeFaults.set(id, { injectedAt: this.simTime });
|
||||
// Logged as 'inject' so the copilot context can filter it out: the copilot
|
||||
// must diagnose from telemetry, not read the answer off an operator log.
|
||||
this.logEvent('inject', profile.station, `Fault injected: ${profile.label}.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearFault(id) {
|
||||
const profile = faultProfile(id);
|
||||
if (!this.activeFaults.delete(id)) return false;
|
||||
// Clearing a bearing fault means the bearing was replaced.
|
||||
if (id === 'bearing-degradation') {
|
||||
this.stationById['CNC-02'].signals.vibration = 1.6;
|
||||
}
|
||||
if (id === 'sensor-dropout') this.stationById['INS-04'].online = true;
|
||||
this.logEvent('inject', profile ? profile.station : 'system', `Fault cleared: ${profile ? profile.label : id}.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearAllFaults() {
|
||||
for (const id of [...this.activeFaults.keys()]) this.clearFault(id);
|
||||
}
|
||||
|
||||
/** Maintenance intervention: fresh tooling resets wear and its knock-on effects. */
|
||||
toolChange() {
|
||||
this.stationById['CNC-02'].signals.toolWear = 2;
|
||||
this.logEvent('action', 'CNC-02', 'Tool change completed. Wear counter reset.');
|
||||
}
|
||||
|
||||
logEvent(kind, station, message) {
|
||||
this.events.push({
|
||||
id: `${this.simTime.toFixed(1)}-${this.events.length}`,
|
||||
t: this.simTime,
|
||||
wallT: Date.now(),
|
||||
kind,
|
||||
station,
|
||||
message,
|
||||
});
|
||||
if (this.events.length > 200) this.events.shift();
|
||||
}
|
||||
|
||||
// -- simulation -----------------------------------------------------------
|
||||
|
||||
/** Advance the model by dt simulated seconds, sub-stepping for stability. */
|
||||
step(dt) {
|
||||
let remaining = dt;
|
||||
while (remaining > 1e-6) {
|
||||
const h = Math.min(MAX_SUBSTEP, remaining);
|
||||
this.subStep(h);
|
||||
remaining -= h;
|
||||
}
|
||||
this.kpi.mark(this.simTime);
|
||||
}
|
||||
|
||||
subStep(dt) {
|
||||
this.simTime += dt;
|
||||
|
||||
const mods = computeModifiers(this.activeFaults, this.simTime);
|
||||
const speedFactor = this.controls.lineSpeedPct / 100;
|
||||
|
||||
for (const st of this.stations) {
|
||||
st.online = !mods.offlineStations.has(st.id);
|
||||
}
|
||||
|
||||
// --- discrete part flow, downstream first ---
|
||||
const last = this.stations.length - 1;
|
||||
for (let i = last; i >= 0; i--) {
|
||||
const st = this.stations[i];
|
||||
|
||||
if (mods.faultedStations.has(st.id)) {
|
||||
st.state = STATE.FAULT;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An in-progress stoppage holds the station regardless of material flow.
|
||||
if (st._stopUntil > this.simTime) {
|
||||
st.state = st._stopKind === 'down' ? STATE.DOWN : STATE.MICROSTOP;
|
||||
continue;
|
||||
}
|
||||
if (st._stopKind) {
|
||||
if (st._stopKind === 'down') {
|
||||
this.logEvent('info', st.id, 'Unplanned stop cleared, station restarted.');
|
||||
}
|
||||
st._stopKind = null;
|
||||
}
|
||||
|
||||
const hasInput = i === 0 || this.buffers[i - 1] > 0;
|
||||
const hasRoom = i === last || this.buffers[i] < BUFFER_CAPACITY;
|
||||
|
||||
if (!hasInput) {
|
||||
st.state = STATE.STARVED;
|
||||
continue;
|
||||
}
|
||||
if (!hasRoom) {
|
||||
st.state = STATE.BLOCKED;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only a station that would otherwise be producing can stall.
|
||||
if (this.maybeStall(st, dt)) {
|
||||
st.state = st._stopKind === 'down' ? STATE.DOWN : STATE.MICROSTOP;
|
||||
continue;
|
||||
}
|
||||
|
||||
st.state = STATE.RUNNING;
|
||||
const cycle = st.baseCycleTime / speedFactor;
|
||||
st.progress += dt / cycle;
|
||||
|
||||
while (st.progress >= 1 && (i === last || this.buffers[i] < BUFFER_CAPACITY)) {
|
||||
st.progress -= 1;
|
||||
if (i > 0) this.buffers[i - 1] -= 1;
|
||||
st.completed += 1;
|
||||
this.onPartCompleted(i, st);
|
||||
}
|
||||
if (st.progress >= 1) st.progress = 0.999; // output filled mid-completion
|
||||
}
|
||||
|
||||
// --- continuous signals ---
|
||||
const achievedRate = this.recentRate(60);
|
||||
const ctx = {
|
||||
rng: this.rng,
|
||||
speedFactor,
|
||||
faults: mods,
|
||||
buffers: this.buffers,
|
||||
stationById: this.stationById,
|
||||
achievedRate,
|
||||
};
|
||||
for (const st of this.stations) updateSignals(st, ctx, dt);
|
||||
|
||||
// --- KPI accumulation ---
|
||||
// Availability counts only real stoppages. Micro-stops are a performance
|
||||
// loss and deliberately do not count here.
|
||||
const lineUp = !this.stations.some((st) => DOWNTIME_STATES.has(st.state));
|
||||
this.kpi.accumulate(dt, lineUp, this.totals.produced, this.totals.good, this.totals.rejected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a producing station stalls this sub-step.
|
||||
*
|
||||
* Returns true if a stoppage started. Micro-stops are silent - they are normal
|
||||
* line behaviour, not events worth alarming on. Unplanned stops are logged.
|
||||
*/
|
||||
maybeStall(st, dt) {
|
||||
// A chattering spindle does not only make bad parts, it stalls the cut. This
|
||||
// is what lets the bearing fault show up in Performance as well as Quality,
|
||||
// so OEE moves for a reason an engineer can name.
|
||||
let microRate = MICRO_STOP_RATE;
|
||||
if (st.kind === 'cnc') {
|
||||
microRate *= 1 + Math.max(0, st.signals.vibration - 2.4) * 1.6;
|
||||
}
|
||||
|
||||
if (this.stallRng() < microRate * dt) {
|
||||
st._stopKind = 'micro';
|
||||
st._stopUntil = this.simTime + MICRO_STOP_MIN + this.stallRng() * MICRO_STOP_SPAN;
|
||||
return true;
|
||||
}
|
||||
if (this.stallRng() < UNPLANNED_STOP_RATE * dt) {
|
||||
st._stopKind = 'down';
|
||||
const secs = UNPLANNED_STOP_MIN + this.stallRng() * UNPLANNED_STOP_SPAN;
|
||||
st._stopUntil = this.simTime + secs;
|
||||
this.logEvent('fault', st.id, `Unplanned stop, estimated ${secs.toFixed(0)} s.`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A part finished at station index i. */
|
||||
onPartCompleted(i, st) {
|
||||
const lastIndex = this.stations.length - 1;
|
||||
|
||||
if (st.kind === 'cnc') {
|
||||
// Tooling wears per part, and a rough bearing chews through it faster.
|
||||
const vib = st.signals.vibration;
|
||||
const accel = 1 + Math.max(0, vib - 2.5) * 0.9;
|
||||
st.signals.toolWear = clamp(st.signals.toolWear + 0.012 * accel, 0, 100);
|
||||
}
|
||||
|
||||
if (i === INSPECTION_INDEX) {
|
||||
st.signals.partsInspected += 1;
|
||||
this.totals.produced += 1;
|
||||
const rejected = this.qualityRng() < st.signals.rejectRate / 100;
|
||||
if (rejected) {
|
||||
this.totals.rejected += 1;
|
||||
return; // scrapped here, never reaches the packer
|
||||
}
|
||||
this.totals.good += 1;
|
||||
this.buffers[i] += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (i === lastIndex) {
|
||||
this.completionTimes.push(this.simTime);
|
||||
if (this.completionTimes.length > 400) this.completionTimes.shift();
|
||||
return;
|
||||
}
|
||||
|
||||
this.buffers[i] += 1;
|
||||
}
|
||||
|
||||
/** Packed units per minute over the trailing window, in simulated time. */
|
||||
recentRate(windowSec) {
|
||||
const cutoff = this.simTime - windowSec;
|
||||
while (this.completionTimes.length && this.completionTimes[0] < cutoff) {
|
||||
this.completionTimes.shift();
|
||||
}
|
||||
const span = Math.min(windowSec, this.simTime);
|
||||
if (span < 5) return 0;
|
||||
return (this.completionTimes.length / span) * 60;
|
||||
}
|
||||
|
||||
// -- output ---------------------------------------------------------------
|
||||
|
||||
snapshot() {
|
||||
const powerKw = instantPower(this.stations);
|
||||
return {
|
||||
t: this.simTime,
|
||||
wallT: Date.now(),
|
||||
lineId: 'LINE-1',
|
||||
stations: this.stations.map((st) => ({
|
||||
id: st.id,
|
||||
name: st.name,
|
||||
kind: st.kind,
|
||||
state: st.state,
|
||||
online: st.online,
|
||||
progress: st.progress,
|
||||
completed: st.completed,
|
||||
signals: { ...st.signals },
|
||||
})),
|
||||
buffers: [...this.buffers],
|
||||
bufferCapacity: BUFFER_CAPACITY,
|
||||
kpi: this.kpi.compute(this.stations, powerKw),
|
||||
faults: [...this.activeFaults.entries()].map(([id, info]) => {
|
||||
const p = faultProfile(id);
|
||||
return {
|
||||
id,
|
||||
label: p.label,
|
||||
short: p.short,
|
||||
severity: p.severity,
|
||||
station: p.station,
|
||||
injectedAt: info.injectedAt,
|
||||
elapsed: this.simTime - info.injectedAt,
|
||||
};
|
||||
}),
|
||||
controls: { ...this.controls },
|
||||
totals: { ...this.totals },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { STATION_SPECS, IDEAL_CYCLE };
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Station specifications and per-station physics.
|
||||
*
|
||||
* A "spec" is static metadata: identity, nominal cycle time, and the signal
|
||||
* definitions (units, ranges, alarm thresholds) that the UI renders generically.
|
||||
* A "station" is the mutable runtime object created from a spec.
|
||||
*
|
||||
* Physics here is deliberately first-order: lag responses, a PID on the oven,
|
||||
* and accumulating wear. It is not a CFD model. What matters for the demo is
|
||||
* that signals move the way an engineer expects them to move, and that they are
|
||||
* coupled - vibration drives tool wear drives reject rate drives OEE.
|
||||
*/
|
||||
|
||||
/** First-order lag toward a target. tau in seconds. */
|
||||
export function lag(current, target, tau, dt) {
|
||||
return current + (target - current) * (1 - Math.exp(-dt / tau));
|
||||
}
|
||||
|
||||
export function clamp(v, lo, hi) {
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
|
||||
/** Deterministic PRNG so every demo run is reproducible. */
|
||||
export function makeRng(seed = 0x5eed) {
|
||||
let a = seed >>> 0;
|
||||
return function rng() {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Zero-centred noise within +/- amp. */
|
||||
function noise(rng, amp) {
|
||||
return (rng() * 2 - 1) * amp;
|
||||
}
|
||||
|
||||
export const STATE = {
|
||||
RUNNING: 'running',
|
||||
STARVED: 'starved',
|
||||
BLOCKED: 'blocked',
|
||||
/** Brief stall of seconds. Conventionally an OEE *performance* loss. */
|
||||
MICROSTOP: 'microstop',
|
||||
/** Unplanned stop of tens of seconds. An OEE *availability* loss. */
|
||||
DOWN: 'down',
|
||||
/** Operator-injected fault from the what-if panel. */
|
||||
FAULT: 'fault',
|
||||
IDLE: 'idle',
|
||||
};
|
||||
|
||||
/** States in which the station is not producing. */
|
||||
export const STOPPED_STATES = new Set([STATE.MICROSTOP, STATE.DOWN, STATE.FAULT, STATE.IDLE]);
|
||||
|
||||
/** States that count against Availability rather than Performance. */
|
||||
export const DOWNTIME_STATES = new Set([STATE.DOWN, STATE.FAULT]);
|
||||
|
||||
/** Buffer capacity between consecutive stations. Small enough to back up fast. */
|
||||
export const BUFFER_CAPACITY = 8;
|
||||
|
||||
/**
|
||||
* Signal spec fields:
|
||||
* key, label, unit, min, max - display and chart scaling
|
||||
* warnHigh/alarmHigh/warnLow/alarmLow - thresholds (all optional)
|
||||
* precision - decimals to render
|
||||
* chart - include in station trend charts
|
||||
* primary - headline signal on the station card
|
||||
* cumulative - monotonically accumulating (wear, counters).
|
||||
* Never anomaly-tested: normal operation drifts
|
||||
* far from any frozen baseline, so a z-score on it
|
||||
* reports growth as a fault. Thresholds and trend
|
||||
* projection are the right tools for these.
|
||||
* volatile - legitimately swings with station state (belt speed
|
||||
* drops to zero on every micro-stop) or is an operator
|
||||
* input rather than a measurement. Also not
|
||||
* anomaly-tested.
|
||||
*/
|
||||
export const STATION_SPECS = [
|
||||
{
|
||||
id: 'CONV-01',
|
||||
name: 'Infeed Conveyor',
|
||||
kind: 'conveyor',
|
||||
baseCycleTime: 4.0,
|
||||
power: 5.5,
|
||||
signals: [
|
||||
{ key: 'beltSpeed', label: 'Belt Speed', unit: 'm/min', min: 0, max: 20, precision: 1, chart: true, primary: true, volatile: true },
|
||||
{ key: 'motorAmps', label: 'Motor Current', unit: 'A', min: 0, max: 24, warnHigh: 16, alarmHigh: 20, precision: 1, chart: true },
|
||||
{ key: 'infeedQueue', label: 'Infeed Queue', unit: 'pcs', min: 0, max: BUFFER_CAPACITY, precision: 0, chart: true, volatile: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'CNC-02',
|
||||
name: 'CNC Machining Centre',
|
||||
kind: 'cnc',
|
||||
baseCycleTime: 4.4,
|
||||
power: 22,
|
||||
signals: [
|
||||
{ key: 'vibration', label: 'Bearing Vibration', unit: 'mm/s RMS', min: 0, max: 6, warnHigh: 3.5, alarmHigh: 4.5, precision: 2, chart: true, primary: true },
|
||||
{ key: 'spindleLoad', label: 'Spindle Load', unit: '%', min: 0, max: 100, warnHigh: 85, alarmHigh: 95, precision: 1, chart: true },
|
||||
{ key: 'spindleRpm', label: 'Spindle Speed', unit: 'rpm', min: 0, max: 10000, precision: 0, chart: true, volatile: true },
|
||||
{ key: 'coolantTemp', label: 'Coolant Temp', unit: '°C', min: 15, max: 80, warnHigh: 52, alarmHigh: 62, precision: 1, chart: true },
|
||||
{ key: 'toolWear', label: 'Tool Wear', unit: '%', min: 0, max: 100, warnHigh: 75, alarmHigh: 92, precision: 1, chart: true, cumulative: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'OVN-03',
|
||||
name: 'Curing Oven',
|
||||
kind: 'oven',
|
||||
baseCycleTime: 4.2,
|
||||
power: 85,
|
||||
signals: [
|
||||
// Range runs to 450 because a saturated burner genuinely overheats the
|
||||
// outer zones when the control zone cannot reach setpoint.
|
||||
{ key: 'zone2Temp', label: 'Zone 2 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true, primary: true },
|
||||
{ key: 'zone1Temp', label: 'Zone 1 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true },
|
||||
{ key: 'zone3Temp', label: 'Zone 3 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true },
|
||||
{ key: 'setpoint', label: 'Setpoint', unit: '°C', min: 200, max: 360, precision: 0, chart: false, volatile: true },
|
||||
{ key: 'burnerDuty', label: 'Burner Duty', unit: '%', min: 0, max: 100, warnHigh: 92, precision: 1, chart: true },
|
||||
{ key: 'tempDeviation', label: 'Temp Deviation', unit: '°C', min: -40, max: 40, warnLow: -8, alarmLow: -18, warnHigh: 8, alarmHigh: 18, precision: 1, chart: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'INS-04',
|
||||
name: 'Vision Inspection',
|
||||
kind: 'inspection',
|
||||
baseCycleTime: 3.6,
|
||||
power: 1.2,
|
||||
signals: [
|
||||
{ key: 'rejectRate', label: 'Reject Rate', unit: '%', min: 0, max: 20, warnHigh: 4, alarmHigh: 8, precision: 2, chart: true, primary: true },
|
||||
{ key: 'cameraConfidence', label: 'Camera Confidence', unit: '%', min: 60, max: 100, warnLow: 90, alarmLow: 80, precision: 1, chart: true },
|
||||
{ key: 'partsInspected', label: 'Parts Inspected', unit: 'pcs', min: 0, max: 100000, precision: 0, chart: false, cumulative: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'PKG-05',
|
||||
name: 'Packer',
|
||||
kind: 'packer',
|
||||
baseCycleTime: 4.1,
|
||||
power: 4.5,
|
||||
signals: [
|
||||
{ key: 'unitsPerMin', label: 'Output Rate', unit: 'u/min', min: 0, max: 20, warnLow: 8, alarmLow: 4, precision: 1, chart: true, primary: true, volatile: true },
|
||||
{ key: 'filmTension', label: 'Film Tension', unit: 'N', min: 0, max: 80, warnHigh: 58, alarmHigh: 68, warnLow: 26, alarmLow: 16, precision: 1, chart: true },
|
||||
{ key: 'downtime', label: 'Downtime', unit: 's', min: 0, max: 100000, precision: 0, chart: false, cumulative: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Look up a signal spec, for thresholds and formatting. */
|
||||
export function signalSpec(stationId, key) {
|
||||
const s = STATION_SPECS.find((x) => x.id === stationId);
|
||||
return s ? s.signals.find((g) => g.key === key) : undefined;
|
||||
}
|
||||
|
||||
export function createStation(spec) {
|
||||
const st = {
|
||||
id: spec.id,
|
||||
name: spec.name,
|
||||
kind: spec.kind,
|
||||
baseCycleTime: spec.baseCycleTime,
|
||||
power: spec.power,
|
||||
state: STATE.IDLE,
|
||||
progress: 0,
|
||||
completed: 0,
|
||||
online: true,
|
||||
signals: {},
|
||||
_integral: 0,
|
||||
_downSec: 0,
|
||||
/** Simulated time at which a micro-stop or unplanned stop ends. */
|
||||
_stopUntil: -1,
|
||||
_stopKind: null,
|
||||
};
|
||||
for (const g of spec.signals) st.signals[g.key] = 0;
|
||||
return st;
|
||||
}
|
||||
|
||||
/** Nominal starting values, so the line does not have to warm up on camera. */
|
||||
export function seedStation(st) {
|
||||
switch (st.kind) {
|
||||
case 'conveyor':
|
||||
st.signals.beltSpeed = 12;
|
||||
st.signals.motorAmps = 8.2;
|
||||
break;
|
||||
case 'cnc':
|
||||
st.signals.vibration = 1.62;
|
||||
st.signals.spindleLoad = 62;
|
||||
st.signals.spindleRpm = 8400;
|
||||
st.signals.coolantTemp = 34;
|
||||
st.signals.toolWear = 18;
|
||||
break;
|
||||
case 'oven':
|
||||
st.signals.setpoint = 305;
|
||||
st.signals.zone1Temp = 303;
|
||||
st.signals.zone2Temp = 305;
|
||||
st.signals.zone3Temp = 301;
|
||||
st.signals.burnerDuty = 68;
|
||||
st.signals.tempDeviation = 0;
|
||||
break;
|
||||
case 'inspection':
|
||||
st.signals.rejectRate = 1.8;
|
||||
st.signals.cameraConfidence = 98.4;
|
||||
break;
|
||||
case 'packer':
|
||||
st.signals.unitsPerMin = 13.6;
|
||||
st.signals.filmTension = 42;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one station's continuous signals by dt simulated seconds.
|
||||
*
|
||||
* ctx carries the cross-station coupling: line speed factor, active fault
|
||||
* modifiers, the rng, buffer levels, and read access to sibling stations - the
|
||||
* oven deviation feeds the inspection reject rate, for example.
|
||||
*/
|
||||
export function updateSignals(st, ctx, dt) {
|
||||
const { rng, speedFactor, faults } = ctx;
|
||||
const running = st.state === STATE.RUNNING;
|
||||
const s = st.signals;
|
||||
|
||||
switch (st.kind) {
|
||||
case 'conveyor': {
|
||||
const jam = st.state === STATE.FAULT;
|
||||
const target = jam ? 0 : running ? 12 * speedFactor : 0;
|
||||
s.beltSpeed = clamp(lag(s.beltSpeed, target, 2.5, dt) + noise(rng, 0.05), 0, 20);
|
||||
const loadAmps = 6.4 + s.beltSpeed * 0.16 + ctx.buffers[0] * 0.09;
|
||||
s.motorAmps = clamp(lag(s.motorAmps, jam ? 19.5 : loadAmps, 3, dt) + noise(rng, 0.12), 0, 24);
|
||||
s.infeedQueue = ctx.buffers[0];
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cnc': {
|
||||
const rpmTarget = running ? 8400 * speedFactor : 0;
|
||||
s.spindleRpm = clamp(lag(s.spindleRpm, rpmTarget, 3.5, dt) + noise(rng, 12), 0, 10000);
|
||||
|
||||
// Bearing degradation adds an exponential ramp on top of the wear-driven
|
||||
// baseline. This is the headline signal of the demo.
|
||||
const bearing = faults.bearingVibration || 0;
|
||||
const vibTarget = 1.55 + s.toolWear * 0.006 + bearing + (running ? 0.06 : -0.55);
|
||||
s.vibration = clamp(lag(s.vibration, vibTarget, 6, dt) + noise(rng, 0.035), 0, 6);
|
||||
|
||||
// A degrading bearing loads the spindle harder for the same cut.
|
||||
const loadTarget = running
|
||||
? 58 + s.toolWear * 0.18 + bearing * 5.5 + (speedFactor - 1) * 22
|
||||
: 4;
|
||||
s.spindleLoad = clamp(lag(s.spindleLoad, loadTarget, 4, dt) + noise(rng, 0.5), 0, 100);
|
||||
|
||||
const coolTarget = 22 + s.spindleLoad * 0.30 + bearing * 2.2;
|
||||
s.coolantTemp = clamp(lag(s.coolantTemp, coolTarget, 45, dt) + noise(rng, 0.08), 15, 80);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'oven': {
|
||||
// PID on zone 2, the control zone, driving burner duty.
|
||||
const err = s.setpoint - s.zone2Temp;
|
||||
st._integral = clamp(st._integral + err * dt, -900, 900);
|
||||
const duty = clamp(0.85 * err + 0.02 * st._integral + 62, 0, 100);
|
||||
s.burnerDuty = lag(s.burnerDuty, duty, 4, dt);
|
||||
|
||||
// A burner fault cuts zone 2 heating capacity. The PID saturates trying to
|
||||
// compensate, so zone 2 sags while zones 1 and 3 drift slightly hot.
|
||||
const cap2 = faults.ovenZone2Capacity ?? 1;
|
||||
const heat = (s.burnerDuty / 100) * 420;
|
||||
s.zone1Temp = lag(s.zone1Temp, 20 + heat * 0.99, 55, dt) + noise(rng, 0.10);
|
||||
s.zone2Temp = lag(s.zone2Temp, 20 + heat * cap2, 48, dt) + noise(rng, 0.10);
|
||||
s.zone3Temp = lag(s.zone3Temp, 20 + heat * 0.97, 60, dt) + noise(rng, 0.10);
|
||||
s.tempDeviation = s.zone2Temp - s.setpoint;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'inspection': {
|
||||
// Sensor dropout: hold the last value rather than fabricating data.
|
||||
if (!st.online) break;
|
||||
|
||||
// Reject rate is driven, not random. Worn tooling and an out-of-spec cure
|
||||
// both push parts out of tolerance. This is the causal chain the copilot
|
||||
// gets to explain.
|
||||
const wear = ctx.stationById['CNC-02'].signals.toolWear;
|
||||
const wearTerm = Math.pow(wear / 100, 2) * 14;
|
||||
const ovenDev = Math.abs(ctx.stationById['OVN-03'].signals.tempDeviation);
|
||||
const ovenTerm = ovenDev > 6 ? (ovenDev - 6) * 0.42 : 0;
|
||||
const vibTerm = Math.max(0, ctx.stationById['CNC-02'].signals.vibration - 2.6) * 1.1;
|
||||
const target = 1.5 + wearTerm + ovenTerm + vibTerm;
|
||||
s.rejectRate = clamp(lag(s.rejectRate, target, 20, dt) + noise(rng, 0.04), 0, 20);
|
||||
s.cameraConfidence = clamp(lag(s.cameraConfidence, 98.5 - ovenTerm * 0.6, 15, dt) + noise(rng, 0.12), 60, 100);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'packer': {
|
||||
if (DOWNTIME_STATES.has(st.state)) {
|
||||
if (st.state === STATE.FAULT) {
|
||||
// Film jam: tension spikes as the web binds, then collapses on tear.
|
||||
s.filmTension = lag(s.filmTension, faults.packerJamPhase === 'tear' ? 4 : 74, 1.5, dt);
|
||||
} else {
|
||||
s.filmTension = lag(s.filmTension, 30, 4, dt);
|
||||
}
|
||||
s.unitsPerMin = lag(s.unitsPerMin, 0, 2, dt);
|
||||
st._downSec += dt;
|
||||
} else {
|
||||
s.filmTension = clamp(lag(s.filmTension, 42 + (speedFactor - 1) * 9, 6, dt) + noise(rng, 0.35), 0, 80);
|
||||
// Achieved rate, derived from real completions in line.js.
|
||||
s.unitsPerMin = clamp(lag(s.unitsPerMin, ctx.achievedRate, 8, dt), 0, 20);
|
||||
}
|
||||
s.downtime = st._downSec;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user