feat: Add optimization execution and live results overlay to canvas

Phase 2 - Execution Bridge:
- Update /start endpoint to fallback to generic runner when no study script exists
- Auto-detect model files (.prt, .sim) from 1_setup/model/ directory
- Pass atomizer_spec.json path to generic runner

Phase 3 - Live Monitoring & Results Overlay:
- Add ResultBadge component for displaying values on canvas nodes
- Extend schema with resultValue and isFeasible fields
- Update DesignVarNode, ObjectiveNode, ConstraintNode, ExtractorNode to show results
- Add Run/Stop buttons and Results toggle to SpecRenderer
- Poll /status endpoint every 3s and map best_trial values to nodes
- Show green/red badges for constraint feasibility
This commit is contained in:
2026-01-21 21:21:47 -05:00
parent f725e75164
commit e1c59a51c1
8 changed files with 236 additions and 4 deletions

View File

@@ -11,6 +11,7 @@
*/
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import { Play, Square, Loader2, Eye, EyeOff } from 'lucide-react';
import ReactFlow, {
Background,
Controls,
@@ -201,6 +202,72 @@ function SpecRendererInner({
const wsStudyId = enableWebSocket ? storeStudyId : null;
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
// Optimization execution state
const [isRunning, setIsRunning] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [bestTrial, setBestTrial] = useState<any>(null);
const [showResults, setShowResults] = useState(false);
// Poll optimization status
useEffect(() => {
if (!studyId) return;
const checkStatus = async () => {
try {
const res = await fetch(`/api/optimization/studies/${studyId}/status`);
if (res.ok) {
const data = await res.json();
setIsRunning(data.status === 'running');
if (data.best_trial) {
setBestTrial(data.best_trial);
// Auto-show results if running or completed and not explicitly disabled
if ((data.status === 'running' || data.status === 'completed') && !showResults) {
setShowResults(true);
}
}
}
} catch (e) {
// Silent fail on polling
}
};
checkStatus();
const interval = setInterval(checkStatus, 3000);
return () => clearInterval(interval);
}, [studyId]);
const handleRun = async () => {
if (!studyId) return;
setIsStarting(true);
try {
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Failed to start');
}
setIsRunning(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start optimization');
} finally {
setIsStarting(false);
}
};
const handleStop = async () => {
if (!studyId) return;
try {
await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
setIsRunning(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to stop optimization');
}
};
// Load spec on mount if studyId provided
useEffect(() => {
if (studyId) {
@@ -214,8 +281,49 @@ function SpecRendererInner({
// Convert spec to ReactFlow nodes
const nodes = useMemo(() => {
return specToNodes(spec);
}, [spec]);
const baseNodes = specToNodes(spec);
if (showResults && bestTrial) {
return baseNodes.map(node => {
const newData = { ...node.data };
// Map results to nodes
if (node.type === 'designVar') {
const val = bestTrial.design_variables?.[newData.expressionName];
if (val !== undefined) newData.resultValue = val;
} else if (node.type === 'objective') {
const outputName = newData.outputName;
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName];
}
} else if (node.type === 'constraint') {
const outputName = newData.outputName;
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) {
const val = bestTrial.results[outputName];
newData.resultValue = val;
// Check feasibility
if (newData.operator === '<=' && newData.value !== undefined) newData.isFeasible = val <= newData.value;
else if (newData.operator === '>=' && newData.value !== undefined) newData.isFeasible = val >= newData.value;
else if (newData.operator === '<' && newData.value !== undefined) newData.isFeasible = val < newData.value;
else if (newData.operator === '>' && newData.value !== undefined) newData.isFeasible = val > newData.value;
else if (newData.operator === '==' && newData.value !== undefined) newData.isFeasible = Math.abs(val - newData.value) < 1e-6;
}
} else if (node.type === 'extractor') {
if (newData.outputNames && newData.outputNames.length > 0 && bestTrial.results) {
const firstOut = newData.outputNames[0];
if (bestTrial.results[firstOut] !== undefined) {
newData.resultValue = bestTrial.results[firstOut];
}
}
}
return { ...node, data: newData };
});
}
return baseNodes;
}, [spec, showResults, bestTrial]);
// Convert spec to ReactFlow edges with selection styling
const edges = useMemo(() => {
@@ -527,6 +635,47 @@ function SpecRendererInner({
/>
</ReactFlow>
{/* Action Buttons */}
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
{bestTrial && (
<button
onClick={() => setShowResults(!showResults)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
showResults
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title={showResults ? "Hide Results" : "Show Best Trial Results"}
>
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
<span className="text-sm font-medium">Results</span>
</button>
)}
{isRunning ? (
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
>
<Square size={16} fill="currentColor" />
Stop Optimization
</button>
) : (
<button
onClick={handleRun}
disabled={isStarting}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 shadow-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStarting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} fill="currentColor" />
)}
Run Optimization
</button>
)}
</div>
{/* Study name badge */}
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>