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

@@ -2114,17 +2114,51 @@ async def start_optimization(study_id: str, request: StartOptimizationRequest =
run_script = study_dir / "run_sat_optimization.py"
if not run_script.exists():
run_script = study_dir / "run_optimization.py"
# Fallback to generic runner if no study script found but spec exists
is_generic_runner = False
if not run_script.exists() and (study_dir / "atomizer_spec.json").exists():
generic_runner = (
Path(__file__).parent.parent.parent.parent.parent
/ "optimization_engine"
/ "run_optimization.py"
)
if generic_runner.exists():
run_script = generic_runner
is_generic_runner = True
if not run_script.exists():
raise HTTPException(
status_code=404, detail=f"No optimization script found for study {study_id}"
)
# Detect script type and build appropriate command
script_type = _detect_script_type(run_script)
script_type = (
_detect_script_type(run_script) if not is_generic_runner else "generic_universal"
)
python_exe = sys.executable
cmd = [python_exe, str(run_script)]
if request:
if is_generic_runner:
# Configure generic runner arguments
cmd.extend(["--config", str(study_dir / "atomizer_spec.json")])
# Auto-detect PRT and SIM files
model_dir = study_dir / "1_setup" / "model"
if not model_dir.exists():
model_dir = study_dir / "model"
prt_files = list(model_dir.glob("*.prt"))
sim_files = list(model_dir.glob("*.sim"))
if prt_files:
cmd.extend(["--prt", str(prt_files[0])])
if sim_files:
cmd.extend(["--sim", str(sim_files[0])])
if request and request.trials:
cmd.extend(["--trials", str(request.trials)])
elif request:
if script_type == "sat":
# SAT scripts use --trials
cmd.extend(["--trials", str(request.trials)])

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>

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { ShieldAlert } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ConstraintNodeData } from '../../../lib/canvas/schema';
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
<ResultBadge value={data.resultValue} isFeasible={data.isFeasible} />
{data.name && data.operator && data.value !== undefined
? `${data.name} ${data.operator} ${data.value}`
: 'Set constraint'}

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { SlidersHorizontal } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { DesignVarNodeData } from '../../../lib/canvas/schema';
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
<ResultBadge value={data.resultValue} unit={data.unit} />
{data.expressionName ? (
<span className="font-mono">{data.expressionName}</span>
) : (

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { FlaskConical } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ExtractorNodeData } from '../../../lib/canvas/schema';
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
<ResultBadge value={data.resultValue} />
{data.extractorName || 'Select extractor'}
</BaseNode>
);

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Target } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
<ResultBadge value={data.resultValue} label="Best" />
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
</BaseNode>
);

View File

@@ -0,0 +1,39 @@
import { memo } from 'react';
interface ResultBadgeProps {
value: number | string | null | undefined;
unit?: string;
isFeasible?: boolean; // For constraints
label?: string;
}
export const ResultBadge = memo(function ResultBadge({ value, unit, isFeasible, label }: ResultBadgeProps) {
if (value === null || value === undefined) return null;
const displayValue = typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 4 })
: value;
// Determine color based on feasibility (if provided)
let bgColor = 'bg-primary-500/20';
let textColor = 'text-primary-300';
let borderColor = 'border-primary-500/30';
if (isFeasible === true) {
bgColor = 'bg-emerald-500/20';
textColor = 'text-emerald-300';
borderColor = 'border-emerald-500/30';
} else if (isFeasible === false) {
bgColor = 'bg-red-500/20';
textColor = 'text-red-300';
borderColor = 'border-red-500/30';
}
return (
<div className={`absolute -top-3 -right-2 px-2 py-0.5 rounded-full border ${bgColor} ${borderColor} ${textColor} text-xs font-mono shadow-lg backdrop-blur-sm z-10 flex items-center gap-1`}>
{label && <span className="opacity-70 mr-1">{label}:</span>}
<span className="font-bold">{displayValue}</span>
{unit && <span className="opacity-70 text-[10px] ml-0.5">{unit}</span>}
</div>
);
});

View File

@@ -16,6 +16,7 @@ export interface BaseNodeData {
label: string;
configured: boolean;
errors?: string[];
resultValue?: number | string | null; // For Results Overlay
}
export interface ModelNodeData extends BaseNodeData {
@@ -105,6 +106,7 @@ export interface ConstraintNodeData extends BaseNodeData {
name?: string;
operator?: '<' | '<=' | '>' | '>=' | '==';
value?: number;
isFeasible?: boolean; // For Results Overlay
}
export interface AlgorithmNodeData extends BaseNodeData {