/** * PlotlyConvergencePlot - Interactive convergence plot using Plotly * * Features: * - Line plot showing objective vs trial number * - Best-so-far trace overlay * - FEA vs NN trial differentiation * - Hover tooltips with trial details * - Range slider for zooming * - Log scale toggle * - Export to PNG/SVG */ import { useMemo, useState } from 'react'; import Plot from 'react-plotly.js'; interface Trial { trial_number: number; values: number[]; params: Record; user_attrs?: Record; source?: 'FEA' | 'NN' | 'V10_FEA'; constraint_satisfied?: boolean; } // Penalty threshold - objectives above this are considered failed/penalty trials const PENALTY_THRESHOLD = 100000; interface PlotlyConvergencePlotProps { trials: Trial[]; objectiveIndex?: number; objectiveName?: string; direction?: 'minimize' | 'maximize'; height?: number; showRangeSlider?: boolean; showLogScaleToggle?: boolean; } export function PlotlyConvergencePlot({ trials, objectiveIndex = 0, objectiveName = 'Objective', direction = 'minimize', height = 400, showRangeSlider = true, showLogScaleToggle = true }: PlotlyConvergencePlotProps) { const [useLogScale, setUseLogScale] = useState(false); // Process trials and calculate best-so-far const { feaData, nnData, bestSoFar, allX, allY } = useMemo(() => { if (!trials.length) return { feaData: { x: [], y: [], text: [] }, nnData: { x: [], y: [], text: [] }, bestSoFar: { x: [], y: [] }, allX: [], allY: [] }; // Sort by trial number const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number); const fea: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] }; const nn: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] }; const best: { x: number[]; y: number[] } = { x: [], y: [] }; const xs: number[] = []; const ys: number[] = []; let bestValue = direction === 'minimize' ? Infinity : -Infinity; sorted.forEach(t => { const val = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName] ?? null; if (val === null || !isFinite(val)) return; // Filter out failed/penalty trials: // 1. Objective above penalty threshold (e.g., 1000000 = solver failure) // 2. constraint_satisfied explicitly false // 3. user_attrs indicates pruned/failed const isPenalty = val >= PENALTY_THRESHOLD; const isFailed = t.constraint_satisfied === false; const isPruned = t.user_attrs?.pruned === true || t.user_attrs?.fail_reason; if (isPenalty || isFailed || isPruned) return; const source = t.source || t.user_attrs?.source || 'FEA'; const hoverText = `Trial #${t.trial_number}
${objectiveName}: ${val.toFixed(4)}
Source: ${source}`; xs.push(t.trial_number); ys.push(val); if (source === 'NN') { nn.x.push(t.trial_number); nn.y.push(val); nn.text.push(hoverText); } else { fea.x.push(t.trial_number); fea.y.push(val); fea.text.push(hoverText); } // Update best-so-far if (direction === 'minimize') { if (val < bestValue) bestValue = val; } else { if (val > bestValue) bestValue = val; } best.x.push(t.trial_number); best.y.push(bestValue); }); return { feaData: fea, nnData: nn, bestSoFar: best, allX: xs, allY: ys }; }, [trials, objectiveIndex, objectiveName, direction]); if (!trials.length || allX.length === 0) { return (
No trial data available
); } const traces: any[] = []; // FEA trials scatter if (feaData.x.length > 0) { traces.push({ type: 'scatter', mode: 'markers', name: `FEA (${feaData.x.length})`, x: feaData.x, y: feaData.y, text: feaData.text, hoverinfo: 'text', marker: { color: '#3B82F6', size: 8, opacity: 0.7, line: { color: '#1E40AF', width: 1 } } }); } // NN trials scatter if (nnData.x.length > 0) { traces.push({ type: 'scatter', mode: 'markers', name: `NN (${nnData.x.length})`, x: nnData.x, y: nnData.y, text: nnData.text, hoverinfo: 'text', marker: { color: '#F97316', size: 6, symbol: 'cross', opacity: 0.6 } }); } // Best-so-far line if (bestSoFar.x.length > 0) { traces.push({ type: 'scatter', mode: 'lines', name: 'Best So Far', x: bestSoFar.x, y: bestSoFar.y, line: { color: '#10B981', width: 3, shape: 'hv' // Step line }, hoverinfo: 'y' }); } const layout: any = { height, margin: { l: 60, r: 30, t: 30, b: showRangeSlider ? 80 : 50 }, paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)', xaxis: { title: 'Trial Number', gridcolor: '#E5E7EB', zerolinecolor: '#D1D5DB', rangeslider: showRangeSlider ? { visible: true } : undefined }, yaxis: { title: useLogScale ? `log₁₀(${objectiveName})` : objectiveName, gridcolor: '#E5E7EB', zerolinecolor: '#D1D5DB', type: useLogScale ? 'log' : 'linear' }, legend: { x: 1, y: 1, xanchor: 'right', bgcolor: 'rgba(255,255,255,0.8)', bordercolor: '#E5E7EB', borderwidth: 1 }, font: { family: 'Inter, system-ui, sans-serif' }, hovermode: 'closest' }; // Best value annotation const bestVal = direction === 'minimize' ? Math.min(...allY) : Math.max(...allY); const bestIdx = allY.indexOf(bestVal); const bestTrial = allX[bestIdx]; return (
{/* Summary stats and controls */}
Best: {bestVal.toFixed(4)} (Trial #{bestTrial})
Current: {allY[allY.length - 1].toFixed(4)}
Trials: {allX.length}
{/* Log scale toggle */} {showLogScaleToggle && ( )}
); }