133 lines
5.1 KiB
React
133 lines
5.1 KiB
React
/**
|
|
* The what-if panel: the controls that make this a twin rather than a dashboard.
|
|
*
|
|
* Setpoints are applied on release rather than on every slider tick, so dragging
|
|
* does not spam the model with intermediate values and pollute the event log.
|
|
*
|
|
* Controls the telemetry source cannot honour are disabled with a reason. A read
|
|
* -only historian or MQTT feed lets you observe the plant, not drive it, and the
|
|
* UI must say so rather than offering buttons that silently do nothing.
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useStore } from '../store.js';
|
|
import { control } from '../hooks/useTelemetry.js';
|
|
|
|
export function WhatIfPanel() {
|
|
const frame = useStore((s) => s.frame);
|
|
const meta = useStore((s) => s.meta);
|
|
const [setpoint, setSetpoint] = useState(305);
|
|
const [lineSpeed, setLineSpeed] = useState(100);
|
|
const [error, setError] = useState(null);
|
|
|
|
const serverSetpoint = frame ? frame.controls.setpoint : 305;
|
|
const serverLineSpeed = frame ? frame.controls.lineSpeedPct : 100;
|
|
|
|
// Follow the server unless the user is mid-drag, so a reset or an external
|
|
// change is reflected here instead of the slider drifting out of sync.
|
|
const [dragging, setDragging] = useState(null);
|
|
useEffect(() => { if (dragging !== 'setpoint') setSetpoint(serverSetpoint); }, [serverSetpoint, dragging]);
|
|
useEffect(() => { if (dragging !== 'lineSpeed') setLineSpeed(serverLineSpeed); }, [serverLineSpeed, dragging]);
|
|
|
|
if (!frame || !meta) return null;
|
|
|
|
const caps = meta.source.capabilities;
|
|
const activeFaults = new Set(frame.faults.map((f) => f.id));
|
|
|
|
const send = async (path, body) => {
|
|
try {
|
|
setError(null);
|
|
await control(path, body);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="card">
|
|
<h2 className="card-title">
|
|
What-if controls
|
|
<span className="hint">{caps.setpointControl ? 'live on the model' : 'read-only source'}</span>
|
|
</h2>
|
|
|
|
{!caps.setpointControl && (
|
|
<div className="banner" style={{ marginBottom: 12 }}>
|
|
This telemetry source is read-only, so setpoints and faults cannot be driven from here.
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="banner" style={{ marginBottom: 12 }}>{error}</div>}
|
|
|
|
<div className="field">
|
|
<div className="field-head">
|
|
<span>Oven setpoint</span>
|
|
<span className="v">{setpoint} °C</span>
|
|
</div>
|
|
<input
|
|
type="range" min={250} max={340} step={1} value={setpoint}
|
|
disabled={!caps.setpointControl}
|
|
onChange={(e) => { setDragging('setpoint'); setSetpoint(Number(e.target.value)); }}
|
|
onMouseUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
|
|
onTouchEnd={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
|
|
onKeyUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
|
|
/>
|
|
<span className="small muted">
|
|
Zone 2 responds with a ~50 s lag. Reject rate climbs once deviation passes about 6 °C.
|
|
</span>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<div className="field-head">
|
|
<span>Line speed</span>
|
|
<span className="v">{lineSpeed}%</span>
|
|
</div>
|
|
<input
|
|
type="range" min={50} max={130} step={1} value={lineSpeed}
|
|
disabled={!caps.setpointControl}
|
|
onChange={(e) => { setDragging('lineSpeed'); setLineSpeed(Number(e.target.value)); }}
|
|
onMouseUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
|
|
onTouchEnd={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
|
|
onKeyUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
|
|
/>
|
|
<span className="small muted">
|
|
CNC-02 is the bottleneck at 4.4 s. Faster cycles raise spindle load.
|
|
</span>
|
|
</div>
|
|
|
|
<div className="divider" />
|
|
|
|
<h2 className="card-title">Inject a fault</h2>
|
|
<div className="fault-list">
|
|
{meta.faults.map((f) => {
|
|
const on = activeFaults.has(f.id);
|
|
return (
|
|
<div className={`fault-row ${on ? 'on' : ''}`} key={f.id}>
|
|
<span className="nm">
|
|
{f.label}
|
|
<small>{f.station} · {f.severity}</small>
|
|
</span>
|
|
<button
|
|
className={on ? 'danger active' : ''}
|
|
disabled={!caps.faultInjection}
|
|
title={f.description}
|
|
onClick={() => send('fault', { id: f.id, action: on ? 'clear' : 'inject' })}
|
|
>
|
|
{on ? 'Clear' : 'Inject'}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="divider" />
|
|
|
|
<div className="inline">
|
|
<button disabled={!caps.setpointControl} onClick={() => send('tool-change', {})}>
|
|
Tool change
|
|
</button>
|
|
<span className="small muted">Resets CNC-02 tool wear, as a maintenance intervention would.</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|