/** * 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.`;