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:
@@ -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)])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user