From e1c59a51c17964a69130931a8a1ade3d632b1927 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Wed, 21 Jan 2026 21:21:47 -0500 Subject: [PATCH] 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 --- .../backend/api/routes/optimization.py | 38 ++++- .../src/components/canvas/SpecRenderer.tsx | 153 +++++++++++++++++- .../canvas/nodes/ConstraintNode.tsx | 2 + .../components/canvas/nodes/DesignVarNode.tsx | 2 + .../components/canvas/nodes/ExtractorNode.tsx | 2 + .../components/canvas/nodes/ObjectiveNode.tsx | 2 + .../components/canvas/nodes/ResultBadge.tsx | 39 +++++ .../frontend/src/lib/canvas/schema.ts | 2 + 8 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 atomizer-dashboard/frontend/src/components/canvas/nodes/ResultBadge.tsx diff --git a/atomizer-dashboard/backend/api/routes/optimization.py b/atomizer-dashboard/backend/api/routes/optimization.py index 0dd85cca..9f1e421c 100644 --- a/atomizer-dashboard/backend/api/routes/optimization.py +++ b/atomizer-dashboard/backend/api/routes/optimization.py @@ -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)]) diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx index 7c8853fa..16fd7939 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -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(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({ /> + {/* Action Buttons */} +
+ {bestTrial && ( + + )} + + {isRunning ? ( + + ) : ( + + )} +
+ {/* Study name badge */}
{spec.meta.study_name} diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ConstraintNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ConstraintNode.tsx index e7a69eec..6716a722 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/ConstraintNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ConstraintNode.tsx @@ -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) { const { data } = props; return ( } iconColor="text-amber-400"> + {data.name && data.operator && data.value !== undefined ? `${data.name} ${data.operator} ${data.value}` : 'Set constraint'} diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx index 83ff6c1b..fbc70086 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx @@ -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) { const { data } = props; return ( } iconColor="text-emerald-400" inputs={0} outputs={1}> + {data.expressionName ? ( {data.expressionName} ) : ( diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ExtractorNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ExtractorNode.tsx index d577a0cc..5360ec6c 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/ExtractorNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ExtractorNode.tsx @@ -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) { const { data } = props; return ( } iconColor="text-cyan-400"> + {data.extractorName || 'Select extractor'} ); diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx index 6a48097d..861e494f 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx @@ -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) { const { data } = props; return ( } iconColor="text-rose-400"> + {data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'} ); diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ResultBadge.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ResultBadge.tsx new file mode 100644 index 00000000..78822522 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ResultBadge.tsx @@ -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 ( +
+ {label && {label}:} + {displayValue} + {unit && {unit}} +
+ ); +}); diff --git a/atomizer-dashboard/frontend/src/lib/canvas/schema.ts b/atomizer-dashboard/frontend/src/lib/canvas/schema.ts index 56b3a976..889b4455 100644 --- a/atomizer-dashboard/frontend/src/lib/canvas/schema.ts +++ b/atomizer-dashboard/frontend/src/lib/canvas/schema.ts @@ -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 {