Initial commit
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
Reference in New Issue
Block a user