From 2cb8dccc3a5db476a6433caedd355c29ae55dbd8 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Wed, 21 Jan 2026 21:48:35 -0500 Subject: [PATCH] feat: Add WebSocket live updates and convergence visualization Phase 4 - Live Updates: - Create useOptimizationStream hook for real-time trial updates - Replace polling with WebSocket subscription in SpecRenderer - Auto-report errors to ErrorPanel via panel store - Add progress tracking (FEA count, NN count, best trial) Phase 5 - Convergence Visualization: - Add ConvergenceSparkline component for mini line charts - Add ProgressRing component for circular progress indicator - Update ObjectiveNode to show convergence trend sparkline - Add history field to ObjectiveNodeData schema - Add live progress indicator centered on canvas when running Bug fixes: - Fix TypeScript errors in FloatingIntrospectionPanel (type casts) - Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId) - Fix NodeConfigPanelV2 unused state variable - Fix specValidator source.extractor_id path - Clean up unused imports across components --- .../src/components/canvas/SpecRenderer.tsx | 265 +++++++------- .../components/canvas/nodes/ObjectiveNode.tsx | 27 +- .../panels/FloatingIntrospectionPanel.tsx | 34 +- .../canvas/panels/NodeConfigPanelV2.tsx | 13 +- .../canvas/panels/ValidationPanel.tsx | 4 +- .../visualization/ConvergenceSparkline.tsx | 240 +++++++++++++ .../src/hooks/useOptimizationStream.ts | 335 ++++++++++++++++++ .../frontend/src/lib/canvas/schema.ts | 1 + .../src/lib/validation/specValidator.ts | 8 +- .../frontend/src/pages/CanvasView.tsx | 4 +- 10 files changed, 764 insertions(+), 167 deletions(-) create mode 100644 atomizer-dashboard/frontend/src/components/canvas/visualization/ConvergenceSparkline.tsx create mode 100644 atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx index 676524fb..3472407f 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -39,7 +39,9 @@ import { } from '../../hooks/useSpecStore'; import { useSpecWebSocket } from '../../hooks/useSpecWebSocket'; import { usePanelStore } from '../../hooks/usePanelStore'; +import { useOptimizationStream } from '../../hooks/useOptimizationStream'; import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; +import { ProgressRing } from './visualization/ConvergenceSparkline'; import { CanvasNodeData } from '../../lib/canvas/schema'; import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator'; @@ -207,107 +209,66 @@ function SpecRendererInner({ // Panel store for validation and error panels const { setValidationData, addError, openPanel } = usePanelStore(); + // Optimization WebSocket stream for real-time updates + const { + status: optimizationStatus, + progress: wsProgress, + bestTrial: wsBestTrial, + recentTrials, + } = useOptimizationStream(studyId, { + autoReportErrors: true, + onTrialComplete: (trial) => { + console.log('[SpecRenderer] Trial completed:', trial.trial_number); + }, + onNewBest: (best) => { + console.log('[SpecRenderer] New best found:', best.value); + setShowResults(true); // Auto-show results when new best found + }, + }); + // Optimization execution state - const [isRunning, setIsRunning] = useState(false); + const isRunning = optimizationStatus === 'running'; const [isStarting, setIsStarting] = useState(false); - const [bestTrial, setBestTrial] = useState(null); const [showResults, setShowResults] = useState(false); const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked'); - // Track last seen error timestamp to avoid duplicates - const lastErrorTimestamp = useRef(0); - - // 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); - } - } - - // Handle errors from the optimization process - if (data.error && data.error_timestamp && data.error_timestamp > lastErrorTimestamp.current) { - lastErrorTimestamp.current = data.error_timestamp; - - // Classify the error based on the message - let errorType: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown' = 'unknown'; - const errorMsg = data.error.toLowerCase(); - - if (errorMsg.includes('nx') || errorMsg.includes('siemens') || errorMsg.includes('journal')) { - errorType = 'nx_crash'; - } else if (errorMsg.includes('solver') || errorMsg.includes('nastran') || errorMsg.includes('convergence')) { - errorType = 'solver_fail'; - } else if (errorMsg.includes('extractor') || errorMsg.includes('extract') || errorMsg.includes('op2')) { - errorType = 'extractor_error'; - } else if (errorMsg.includes('config') || errorMsg.includes('spec') || errorMsg.includes('parameter')) { - errorType = 'config_error'; - } else if (errorMsg.includes('system') || errorMsg.includes('permission') || errorMsg.includes('disk')) { - errorType = 'system_error'; - } - - // Generate suggestions based on error type - const suggestions: string[] = []; - switch (errorType) { - case 'nx_crash': - suggestions.push('Check if NX is running and licensed'); - suggestions.push('Verify the model file is not corrupted'); - suggestions.push('Try closing and reopening NX'); - break; - case 'solver_fail': - suggestions.push('Check the mesh quality in the FEM file'); - suggestions.push('Verify boundary conditions are properly defined'); - suggestions.push('Review the solver settings'); - break; - case 'extractor_error': - suggestions.push('Verify the OP2 file was created successfully'); - suggestions.push('Check if the extractor type matches the analysis'); - suggestions.push('For custom extractors, review the code for errors'); - break; - case 'config_error': - suggestions.push('Run validation to check the spec'); - suggestions.push('Verify all design variables have valid bounds'); - break; - default: - suggestions.push('Check the optimization logs for more details'); - } - - addError({ - type: errorType, - trial: data.current_trial, - message: data.error, - details: data.error_details, - recoverable: errorType !== 'config_error', - suggestions, - timestamp: data.error_timestamp || Date.now(), - }); - } - - // Handle failed status - if (data.status === 'failed' && data.error) { - setIsRunning(false); - } - } - } catch (e) { - // Silent fail on polling - network issues shouldn't spam errors - console.debug('Status poll failed:', e); + // Build trial history for sparklines (extract objective values from recent trials) + const trialHistory = useMemo(() => { + const history: Record = {}; + for (const trial of recentTrials) { + // Map objective values - assumes single objective for now + if (trial.objective !== null) { + const key = 'primary'; + if (!history[key]) history[key] = []; + history[key].push(trial.objective); } + // Could also extract individual params/results for multi-objective + } + // Reverse so oldest is first (for sparkline) + for (const key of Object.keys(history)) { + history[key].reverse(); + } + return history; + }, [recentTrials]); + + // Build best trial data for node display + const bestTrial = useMemo((): { + trial_number: number; + objective: number; + design_variables: Record; + results: Record; + } | null => { + if (!wsBestTrial) return null; + return { + trial_number: wsBestTrial.trial_number, + objective: wsBestTrial.value, + design_variables: wsBestTrial.params, + results: { primary: wsBestTrial.value, ...wsBestTrial.params }, }; - - checkStatus(); - const interval = setInterval(checkStatus, 3000); - return () => clearInterval(interval); - }, [studyId, addError]); + }, [wsBestTrial]); + + // Note: Polling removed - now using WebSocket via useOptimizationStream hook + // The hook handles: status updates, best trial updates, error reporting // Validate the spec and show results in panel const handleValidate = useCallback(() => { @@ -359,7 +320,7 @@ function SpecRendererInner({ const err = await res.json(); throw new Error(err.detail || 'Failed to start'); } - setIsRunning(true); + // isRunning is now derived from WebSocket state (optimizationStatus === 'running') setValidationStatus('unchecked'); // Clear validation status when running } catch (e) { const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization'; @@ -386,7 +347,7 @@ function SpecRendererInner({ const err = await res.json().catch(() => ({})); throw new Error(err.detail || 'Failed to stop'); } - setIsRunning(false); + // isRunning will update via WebSocket when optimization actually stops } catch (e) { const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization'; setError(errorMessage); @@ -415,47 +376,56 @@ function SpecRendererInner({ const nodes = useMemo(() => { const baseNodes = specToNodes(spec); - if (showResults && bestTrial) { - return baseNodes.map(node => { - const newData = { ...node.data }; - - // Map results to nodes - if (node.type === 'designVar') { + // Always map nodes to include history for sparklines (even if not showing results) + return baseNodes.map(node => { + // Create a mutable copy with explicit any type for dynamic property assignment + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newData: any = { ...node.data }; + + // Add history for sparklines on objective nodes + if (node.type === 'objective') { + newData.history = trialHistory['primary'] || []; + } + + // Map results to nodes when showing results + if (showResults && bestTrial) { + if (node.type === 'designVar' && newData.expressionName) { 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]; - } + const outputName = newData.outputName; + if (outputName && 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; - } + const outputName = newData.outputName; + if (outputName && bestTrial.results?.[outputName] !== undefined) { + const val = bestTrial.results[outputName]; + newData.resultValue = val; + + // Check feasibility + const op = newData.operator; + const threshold = newData.value; + if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold; + else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold; + else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold; + else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold; + else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 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]; - } - } + const outputNames = newData.outputNames; + if (outputNames && outputNames.length > 0 && bestTrial.results) { + const firstOut = outputNames[0]; + if (bestTrial.results[firstOut] !== undefined) { + newData.resultValue = bestTrial.results[firstOut]; + } + } } - - return { ...node, data: newData }; - }); - } - - return baseNodes; - }, [spec, showResults, bestTrial]); + } + + return { ...node, data: newData }; + }); + }, [spec, showResults, bestTrial, trialHistory]); // Convert spec to ReactFlow edges with selection styling const edges = useMemo(() => { @@ -841,6 +811,39 @@ function SpecRendererInner({
{spec.meta.study_name}
+ + {/* Progress indicator when running */} + {isRunning && wsProgress && ( +
+ +
+ + Trial {wsProgress.current} / {wsProgress.total} + + + {wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`} + {wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '} + {wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`} + {wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'} + +
+ {wsBestTrial && ( +
+ Best + + {typeof wsBestTrial.value === 'number' + ? wsBestTrial.value.toFixed(4) + : wsBestTrial.value} + +
+ )} +
+ )} ); } diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx index 861e494f..26fec394 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ObjectiveNode.tsx @@ -3,14 +3,37 @@ import { NodeProps } from 'reactflow'; import { Target } from 'lucide-react'; import { BaseNode } from './BaseNode'; import { ResultBadge } from './ResultBadge'; +import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline'; import { ObjectiveNodeData } from '../../../lib/canvas/schema'; function ObjectiveNodeComponent(props: NodeProps) { const { data } = props; + const hasHistory = data.history && data.history.length > 1; + return ( } iconColor="text-rose-400"> - - {data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'} +
+
+ + {data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'} + + +
+ + {/* Convergence sparkline */} + {hasHistory && ( +
+ +
+ )} +
); } diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx index fc03390e..1a7a429e 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx @@ -15,27 +15,15 @@ import { Plus, ChevronDown, ChevronRight, - FileBox, Cpu, SlidersHorizontal, - AlertTriangle, Scale, - Link, - Box, - Settings2, - GitBranch, - File, - Grid3x3, - Target, - Zap, - Layers, Minimize2, Maximize2, } from 'lucide-react'; import { useIntrospectionPanel, usePanelStore, - IntrospectionData, } from '../../../hooks/usePanelStore'; import { useSpecStore } from '../../../hooks/useSpecStore'; @@ -63,6 +51,22 @@ interface ExpressionsResult { user_count: number; } +interface IntrospectionResult { + solver_type?: string; + expressions?: ExpressionsResult; + // Allow other properties from the API response + file_deps?: unknown[]; + fea_results?: unknown[]; + fem_mesh?: unknown; + sim_solutions?: unknown[]; + sim_bcs?: unknown[]; + mass_properties?: { + total_mass?: number; + center_of_gravity?: { x: number; y: number; z: number }; + [key: string]: unknown; + }; +} + interface ModelFileInfo { name: string; stem: string; @@ -103,9 +107,9 @@ export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPan const [isLoadingFiles, setIsLoadingFiles] = useState(false); const data = panel.data; - const result = data?.result; + const result = data?.result as IntrospectionResult | undefined; const isLoading = data?.isLoading || false; - const error = data?.error; + const error = data?.error as string | null; // Fetch available files when studyId changes const fetchAvailableFiles = useCallback(async () => { @@ -209,7 +213,7 @@ export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPan const minValue = expr.min ?? expr.value * 0.5; const maxValue = expr.max ?? expr.value * 1.5; - addNode('dv', { + addNode('designVar', { name: expr.name, expression_name: expr.name, type: 'continuous', diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx index 9061a7ed..173bb3d6 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx @@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) { const { updateNode, removeNode, clearSelection } = useSpecStore(); const [showFileBrowser, setShowFileBrowser] = useState(false); - const [showIntrospection, setShowIntrospection] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [error, setError] = useState(null); @@ -249,16 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) { fileTypes={['.sim', '.prt', '.fem', '.afem']} /> - {/* Introspection Panel */} - {showIntrospection && spec.model.sim?.path && ( -
- setShowIntrospection(false)} - /> -
- )} + {/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */} ); } @@ -280,6 +270,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) { filePath: spec.model.sim?.path || '', studyId: useSpecStore.getState().studyId || undefined, }); + openPanel('introspection'); }; return ( diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx index 4ff37cc1..250e9914 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx @@ -73,7 +73,7 @@ interface FloatingValidationPanelProps { export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) { const panel = useValidationPanel(); const { minimizePanel } = usePanelStore(); - const { setSelectedNodeId } = useSpecStore(); + const { selectNode } = useSpecStore(); const { errors, warnings, valid } = useMemo(() => { if (!panel.data) { @@ -88,7 +88,7 @@ export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProp const handleNavigateToNode = (nodeId?: string) => { if (nodeId) { - setSelectedNodeId(nodeId); + selectNode(nodeId); } }; diff --git a/atomizer-dashboard/frontend/src/components/canvas/visualization/ConvergenceSparkline.tsx b/atomizer-dashboard/frontend/src/components/canvas/visualization/ConvergenceSparkline.tsx new file mode 100644 index 00000000..35963014 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/visualization/ConvergenceSparkline.tsx @@ -0,0 +1,240 @@ +/** + * ConvergenceSparkline - Tiny SVG chart showing optimization convergence + * + * Displays the last N trial values as a mini line chart. + * Used on ObjectiveNode to show convergence trend. + */ + +import { useMemo } from 'react'; + +interface ConvergenceSparklineProps { + /** Array of values (most recent last) */ + values: number[]; + /** Width in pixels */ + width?: number; + /** Height in pixels */ + height?: number; + /** Line color */ + color?: string; + /** Best value line color */ + bestColor?: string; + /** Whether to show the best value line */ + showBest?: boolean; + /** Direction: minimize shows lower as better, maximize shows higher as better */ + direction?: 'minimize' | 'maximize'; + /** Show dots at each point */ + showDots?: boolean; + /** Number of points to display */ + maxPoints?: number; +} + +export function ConvergenceSparkline({ + values, + width = 80, + height = 24, + color = '#60a5fa', + bestColor = '#34d399', + showBest = true, + direction = 'minimize', + showDots = false, + maxPoints = 20, +}: ConvergenceSparklineProps) { + const { path, bestY, points } = useMemo(() => { + if (!values || values.length === 0) { + return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 }; + } + + // Take last N points + const data = values.slice(-maxPoints); + if (data.length === 0) { + return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 }; + } + + // Calculate bounds with padding + const minVal = Math.min(...data); + const maxVal = Math.max(...data); + const range = maxVal - minVal || 1; + const padding = range * 0.1; + const yMin = minVal - padding; + const yMax = maxVal + padding; + const yRange = yMax - yMin; + + // Calculate best value + const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data); + + // Map values to SVG coordinates + const xStep = width / Math.max(data.length - 1, 1); + const mapY = (v: number) => height - ((v - yMin) / yRange) * height; + + // Build path + const points = data.map((v, i) => ({ + x: i * xStep, + y: mapY(v), + value: v, + })); + + const pathParts = points.map((p, i) => + i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}` + ); + + return { + path: pathParts.join(' '), + bestY: mapY(bestVal), + points, + minVal, + maxVal, + }; + }, [values, width, height, maxPoints, direction]); + + if (!values || values.length === 0) { + return ( +
+ No data +
+ ); + } + + return ( + + {/* Best value line */} + {showBest && bestY !== null && ( + + )} + + {/* Main line */} + + + {/* Gradient fill under the line */} + + + + + + + + {points.length > 1 && ( + + )} + + {/* Dots at each point */} + {showDots && points.map((p, i) => ( + + ))} + + {/* Last point highlight */} + {points.length > 0 && ( + + )} + + ); +} + +/** + * ProgressRing - Circular progress indicator + */ +interface ProgressRingProps { + /** Progress percentage (0-100) */ + progress: number; + /** Size in pixels */ + size?: number; + /** Stroke width */ + strokeWidth?: number; + /** Progress color */ + color?: string; + /** Background color */ + bgColor?: string; + /** Show percentage text */ + showText?: boolean; +} + +export function ProgressRing({ + progress, + size = 32, + strokeWidth = 3, + color = '#60a5fa', + bgColor = '#374151', + showText = true, +}: ProgressRingProps) { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference; + + return ( +
+ + {/* Background circle */} + + {/* Progress circle */} + + + {showText && ( + + {Math.round(progress)}% + + )} +
+ ); +} + +export default ConvergenceSparkline; diff --git a/atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts b/atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts new file mode 100644 index 00000000..48fe0e81 --- /dev/null +++ b/atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts @@ -0,0 +1,335 @@ +/** + * useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates + * + * This hook provides: + * - Real-time trial updates (no polling needed) + * - Best trial tracking + * - Progress tracking + * - Error detection and reporting + * - Integration with panel store for error display + * - Automatic reconnection + * + * Usage: + * ```tsx + * const { + * isConnected, + * progress, + * bestTrial, + * recentTrials, + * status + * } = useOptimizationStream(studyId); + * ``` + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import useWebSocket, { ReadyState } from 'react-use-websocket'; +import { usePanelStore } from './usePanelStore'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface TrialData { + trial_number: number; + trial_num: number; + objective: number | null; + values: number[]; + params: Record; + user_attrs: Record; + source: 'FEA' | 'NN' | string; + start_time: string; + end_time: string; + study_name: string; + constraint_satisfied: boolean; +} + +export interface ProgressData { + current: number; + total: number; + percentage: number; + fea_count: number; + nn_count: number; + timestamp: string; +} + +export interface BestTrialData { + trial_number: number; + value: number; + params: Record; + improvement: number; +} + +export interface ParetoData { + pareto_front: Array<{ + trial_number: number; + values: number[]; + params: Record; + constraint_satisfied: boolean; + source: string; + }>; + count: number; +} + +export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed'; + +export interface OptimizationStreamState { + isConnected: boolean; + status: OptimizationStatus; + progress: ProgressData | null; + bestTrial: BestTrialData | null; + recentTrials: TrialData[]; + paretoFront: ParetoData | null; + lastUpdate: number | null; + error: string | null; +} + +// ============================================================================ +// Hook +// ============================================================================ + +interface UseOptimizationStreamOptions { + /** Maximum number of recent trials to keep */ + maxRecentTrials?: number; + /** Callback when a new trial completes */ + onTrialComplete?: (trial: TrialData) => void; + /** Callback when a new best is found */ + onNewBest?: (best: BestTrialData) => void; + /** Callback on progress update */ + onProgress?: (progress: ProgressData) => void; + /** Whether to auto-report errors to the error panel */ + autoReportErrors?: boolean; +} + +export function useOptimizationStream( + studyId: string | null | undefined, + options: UseOptimizationStreamOptions = {} +) { + const { + maxRecentTrials = 20, + onTrialComplete, + onNewBest, + onProgress, + autoReportErrors = true, + } = options; + + // Panel store for error reporting + const { addError } = usePanelStore(); + + // State + const [state, setState] = useState({ + isConnected: false, + status: 'disconnected', + progress: null, + bestTrial: null, + recentTrials: [], + paretoFront: null, + lastUpdate: null, + error: null, + }); + + // Track last error timestamp to avoid duplicates + const lastErrorTime = useRef(0); + + // Build WebSocket URL + const socketUrl = studyId + ? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${ + import.meta.env.DEV ? 'localhost:8001' : window.location.host + }/api/ws/optimization/${encodeURIComponent(studyId)}` + : null; + + // WebSocket connection + const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, { + shouldReconnect: () => true, + reconnectAttempts: 10, + reconnectInterval: 3000, + onOpen: () => { + console.log('[OptStream] Connected to optimization stream'); + setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null })); + }, + onClose: () => { + console.log('[OptStream] Disconnected from optimization stream'); + setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' })); + }, + onError: (event) => { + console.error('[OptStream] WebSocket error:', event); + setState(prev => ({ ...prev, error: 'WebSocket connection error' })); + }, + }); + + // Update connection status + useEffect(() => { + const statusMap: Record = { + [ReadyState.CONNECTING]: 'connecting', + [ReadyState.OPEN]: 'connected', + [ReadyState.CLOSING]: 'disconnected', + [ReadyState.CLOSED]: 'disconnected', + [ReadyState.UNINSTANTIATED]: 'disconnected', + }; + + setState(prev => ({ + ...prev, + isConnected: readyState === ReadyState.OPEN, + status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed' + ? prev.status + : statusMap[readyState] || 'disconnected', + })); + }, [readyState]); + + // Process incoming messages + useEffect(() => { + if (!lastMessage?.data) return; + + try { + const message = JSON.parse(lastMessage.data); + const { type, data } = message; + + switch (type) { + case 'connected': + console.log('[OptStream] Connection confirmed:', data.message); + break; + + case 'trial_completed': + handleTrialComplete(data as TrialData); + break; + + case 'new_best': + handleNewBest(data as BestTrialData); + break; + + case 'progress': + handleProgress(data as ProgressData); + break; + + case 'pareto_update': + handleParetoUpdate(data as ParetoData); + break; + + case 'heartbeat': + case 'pong': + // Keep-alive messages + break; + + case 'error': + handleError(data); + break; + + default: + console.log('[OptStream] Unknown message type:', type, data); + } + } catch (e) { + console.error('[OptStream] Failed to parse message:', e); + } + }, [lastMessage]); + + // Handler functions + const handleTrialComplete = useCallback((trial: TrialData) => { + setState(prev => { + const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials); + return { + ...prev, + recentTrials: newTrials, + lastUpdate: Date.now(), + status: 'running', + }; + }); + + onTrialComplete?.(trial); + }, [maxRecentTrials, onTrialComplete]); + + const handleNewBest = useCallback((best: BestTrialData) => { + setState(prev => ({ + ...prev, + bestTrial: best, + lastUpdate: Date.now(), + })); + + onNewBest?.(best); + }, [onNewBest]); + + const handleProgress = useCallback((progress: ProgressData) => { + setState(prev => { + // Determine status based on progress + let status: OptimizationStatus = prev.status; + if (progress.current > 0 && progress.current < progress.total) { + status = 'running'; + } else if (progress.current >= progress.total) { + status = 'completed'; + } + + return { + ...prev, + progress, + status, + lastUpdate: Date.now(), + }; + }); + + onProgress?.(progress); + }, [onProgress]); + + const handleParetoUpdate = useCallback((pareto: ParetoData) => { + setState(prev => ({ + ...prev, + paretoFront: pareto, + lastUpdate: Date.now(), + })); + }, []); + + const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => { + const now = Date.now(); + + // Avoid duplicate errors within 5 seconds + if (now - lastErrorTime.current < 5000) return; + lastErrorTime.current = now; + + setState(prev => ({ + ...prev, + error: errorData.message, + status: 'failed', + })); + + if (autoReportErrors) { + addError({ + type: 'system_error', + message: errorData.message, + details: errorData.details, + trial: errorData.trial, + recoverable: true, + suggestions: ['Check the optimization logs', 'Try restarting the optimization'], + timestamp: now, + }); + } + }, [autoReportErrors, addError]); + + // Send ping to keep connection alive + useEffect(() => { + if (readyState !== ReadyState.OPEN) return; + + const interval = setInterval(() => { + sendMessage(JSON.stringify({ type: 'ping' })); + }, 25000); // Ping every 25 seconds + + return () => clearInterval(interval); + }, [readyState, sendMessage]); + + // Reset state when study changes + useEffect(() => { + setState({ + isConnected: false, + status: 'disconnected', + progress: null, + bestTrial: null, + recentTrials: [], + paretoFront: null, + lastUpdate: null, + error: null, + }); + }, [studyId]); + + return { + ...state, + sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })), + }; +} + +export default useOptimizationStream; diff --git a/atomizer-dashboard/frontend/src/lib/canvas/schema.ts b/atomizer-dashboard/frontend/src/lib/canvas/schema.ts index 889b4455..c3c45918 100644 --- a/atomizer-dashboard/frontend/src/lib/canvas/schema.ts +++ b/atomizer-dashboard/frontend/src/lib/canvas/schema.ts @@ -99,6 +99,7 @@ export interface ObjectiveNodeData extends BaseNodeData { extractorRef?: string; // Reference to extractor ID outputName?: string; // Which output from the extractor penaltyWeight?: number; // For hard constraints (penalty method) + history?: number[]; // Recent values for sparkline visualization } export interface ConstraintNodeData extends BaseNodeData { diff --git a/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts b/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts index 4d413c89..fee82c17 100644 --- a/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts +++ b/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts @@ -5,7 +5,7 @@ * returning structured errors that can be displayed in the ValidationPanel. */ -import { AtomizerSpec, DesignVariable, Extractor, Objective, Constraint } from '../../types/atomizer-spec'; +import { AtomizerSpec } from '../../types/atomizer-spec'; import { ValidationError, ValidationData } from '../../hooks/usePanelStore'; // ============================================================================ @@ -178,9 +178,9 @@ const validationRules: ValidationRule[] = [ edge => edge.target === obj.id && edge.source.startsWith('ext_') ); - // Also check if source_extractor_id is set - const hasDirectSource = obj.source_extractor_id && - spec.extractors.some(e => e.id === obj.source_extractor_id); + // Also check if source.extractor_id is set + const hasDirectSource = obj.source?.extractor_id && + spec.extractors.some(e => e.id === obj.source.extractor_id); if (!hasSource && !hasDirectSource) { return { diff --git a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx index 3e880e20..6ad1b265 100644 --- a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx +++ b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2, CheckCircle } from 'lucide-react'; +import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2 } from 'lucide-react'; import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas'; import { SpecRenderer } from '../components/canvas/SpecRenderer'; import { NodePalette } from '../components/canvas/palette/NodePalette'; @@ -13,7 +13,7 @@ import { ChatPanel } from '../components/canvas/panels/ChatPanel'; import { PanelContainer } from '../components/canvas/panels/PanelContainer'; import { useCanvasStore } from '../hooks/useCanvasStore'; import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore'; -import { usePanelStore } from '../hooks/usePanelStore'; +// usePanelStore is now used by child components - PanelContainer handles panels import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo'; import { useStudy } from '../context/StudyContext'; import { useChat } from '../hooks/useChat';