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"
|
run_script = study_dir / "run_sat_optimization.py"
|
||||||
if not run_script.exists():
|
if not run_script.exists():
|
||||||
run_script = study_dir / "run_optimization.py"
|
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():
|
if not run_script.exists():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"No optimization script found for study {study_id}"
|
status_code=404, detail=f"No optimization script found for study {study_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Detect script type and build appropriate command
|
# 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
|
python_exe = sys.executable
|
||||||
cmd = [python_exe, str(run_script)]
|
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":
|
if script_type == "sat":
|
||||||
# SAT scripts use --trials
|
# SAT scripts use --trials
|
||||||
cmd.extend(["--trials", str(request.trials)])
|
cmd.extend(["--trials", str(request.trials)])
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
|
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
|
||||||
|
import { Play, Square, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
@@ -201,6 +202,72 @@ function SpecRendererInner({
|
|||||||
const wsStudyId = enableWebSocket ? storeStudyId : null;
|
const wsStudyId = enableWebSocket ? storeStudyId : null;
|
||||||
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
|
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
|
// Load spec on mount if studyId provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (studyId) {
|
if (studyId) {
|
||||||
@@ -214,8 +281,49 @@ function SpecRendererInner({
|
|||||||
|
|
||||||
// Convert spec to ReactFlow nodes
|
// Convert spec to ReactFlow nodes
|
||||||
const nodes = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
return specToNodes(spec);
|
const baseNodes = specToNodes(spec);
|
||||||
}, [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
|
// Convert spec to ReactFlow edges with selection styling
|
||||||
const edges = useMemo(() => {
|
const edges = useMemo(() => {
|
||||||
@@ -527,6 +635,47 @@ function SpecRendererInner({
|
|||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</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 */}
|
{/* 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">
|
<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>
|
<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 { NodeProps } from 'reactflow';
|
||||||
import { ShieldAlert } from 'lucide-react';
|
import { ShieldAlert } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
|
<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 !== undefined
|
||||||
? `${data.name} ${data.operator} ${data.value}`
|
? `${data.name} ${data.operator} ${data.value}`
|
||||||
: 'Set constraint'}
|
: 'Set constraint'}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { SlidersHorizontal } from 'lucide-react';
|
import { SlidersHorizontal } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
||||||
|
<ResultBadge value={data.resultValue} unit={data.unit} />
|
||||||
{data.expressionName ? (
|
{data.expressionName ? (
|
||||||
<span className="font-mono">{data.expressionName}</span>
|
<span className="font-mono">{data.expressionName}</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { FlaskConical } from 'lucide-react';
|
import { FlaskConical } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
||||||
|
<ResultBadge value={data.resultValue} />
|
||||||
{data.extractorName || 'Select extractor'}
|
{data.extractorName || 'Select extractor'}
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { Target } from 'lucide-react';
|
import { Target } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
|
<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'}
|
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||||
</BaseNode>
|
</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;
|
label: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
|
resultValue?: number | string | null; // For Results Overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelNodeData extends BaseNodeData {
|
export interface ModelNodeData extends BaseNodeData {
|
||||||
@@ -105,6 +106,7 @@ export interface ConstraintNodeData extends BaseNodeData {
|
|||||||
name?: string;
|
name?: string;
|
||||||
operator?: '<' | '<=' | '>' | '>=' | '==';
|
operator?: '<' | '<=' | '>' | '>=' | '==';
|
||||||
value?: number;
|
value?: number;
|
||||||
|
isFeasible?: boolean; // For Results Overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlgorithmNodeData extends BaseNodeData {
|
export interface AlgorithmNodeData extends BaseNodeData {
|
||||||
|
|||||||
Reference in New Issue
Block a user