/** * Convergence Plot * Shows optimization progress over time with running best and improvement rate * Features log scale toggle to better visualize early improvements */ import { useMemo, useState } from 'react'; import { Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, ComposedChart, Legend } from 'recharts'; interface Trial { trial_number: number; values: number[]; state?: string; constraint_satisfied?: boolean; user_attrs?: Record; } // Penalty threshold - objectives above this are considered failed/penalty trials const PENALTY_THRESHOLD = 100000; interface ConvergencePlotProps { trials: Trial[]; objectiveIndex?: number; objectiveName?: string; direction?: 'minimize' | 'maximize'; showLogScaleToggle?: boolean; } export function ConvergencePlot({ trials, objectiveIndex = 0, objectiveName = 'Objective', direction = 'minimize', showLogScaleToggle = true }: ConvergencePlotProps) { const [useLogScale, setUseLogScale] = useState(false); const [zoomToTail, setZoomToTail] = useState(false); const convergenceData = useMemo(() => { if (!trials || trials.length === 0) return []; // Sort by trial number, filtering out failed/penalty trials const sortedTrials = [...trials] .filter(t => { // Must have valid values if (!t.values || t.values.length <= objectiveIndex) return false; // Filter out failed state if (t.state === 'FAIL') return false; // Filter out penalty values (e.g., 1000000 = solver failure) const val = t.values[objectiveIndex]; if (val >= PENALTY_THRESHOLD) return false; // Filter out constraint violations if (t.constraint_satisfied === false) return false; // Filter out pruned trials if (t.user_attrs?.pruned === true || t.user_attrs?.fail_reason) return false; return true; }) .sort((a, b) => a.trial_number - b.trial_number); if (sortedTrials.length === 0) return []; let runningBest = direction === 'minimize' ? Infinity : -Infinity; const data = sortedTrials.map((trial, idx) => { const value = trial.values[objectiveIndex]; // Update running best if (direction === 'minimize') { runningBest = Math.min(runningBest, value); } else { runningBest = Math.max(runningBest, value); } // Calculate improvement from first trial const firstValue = sortedTrials[0].values[objectiveIndex]; const improvement = direction === 'minimize' ? ((firstValue - runningBest) / firstValue) * 100 : ((runningBest - firstValue) / firstValue) * 100; return { trial: trial.trial_number, value, best: runningBest, improvement: Math.max(0, improvement), index: idx + 1 }; }); return data; }, [trials, objectiveIndex, direction]); // Calculate statistics const stats = useMemo(() => { if (convergenceData.length === 0) return null; const values = convergenceData.map(d => d.value); const best = convergenceData[convergenceData.length - 1]?.best ?? 0; const first = convergenceData[0]?.value ?? 0; const improvement = direction === 'minimize' ? ((first - best) / first) * 100 : ((best - first) / first) * 100; // Calculate when we found 90% of the improvement const targetImprovement = 0.9 * improvement; let trialsTo90 = convergenceData.length; for (let i = 0; i < convergenceData.length; i++) { if (convergenceData[i].improvement >= targetImprovement) { trialsTo90 = i + 1; break; } } return { best, first, improvement: Math.max(0, improvement), totalTrials: convergenceData.length, trialsTo90, mean: values.reduce((a, b) => a + b, 0) / values.length, std: Math.sqrt(values.map(v => Math.pow(v - values.reduce((a, b) => a + b, 0) / values.length, 2)).reduce((a, b) => a + b, 0) / values.length) }; }, [convergenceData, direction]); // Transform data for log scale display const displayData = useMemo(() => { if (!useLogScale) { return zoomToTail && convergenceData.length > 20 ? convergenceData.slice(-Math.ceil(convergenceData.length * 0.2)) : convergenceData; } // For log scale, we need to handle values <= 0 // Shift all values to be positive if needed const minVal = Math.min(...convergenceData.map(d => d.value), ...convergenceData.map(d => d.best)); const offset = minVal <= 0 ? Math.abs(minVal) + 1 : 0; const transformed = convergenceData.map(d => ({ ...d, value: Math.log10(d.value + offset), best: Math.log10(d.best + offset), originalValue: d.value, originalBest: d.best })); return zoomToTail && transformed.length > 20 ? transformed.slice(-Math.ceil(transformed.length * 0.2)) : transformed; }, [convergenceData, useLogScale, zoomToTail]); if (convergenceData.length === 0) { return (

Convergence Plot

No trial data available
); } return (

Convergence Plot

{objectiveName} over {convergenceData.length} trials {useLogScale && (log₁₀ scale)} {zoomToTail && (last 20%)}

{/* Scale toggle buttons */} {showLogScaleToggle && (
)} {stats && (
Best
{stats.best.toFixed(4)}
Improvement
{stats.improvement.toFixed(1)}%
90% at
#{stats.trialsTo90}
)}
useLogScale ? `10^${v.toFixed(1)}` : v.toFixed(2)} label={{ value: useLogScale ? `log₁₀(${objectiveName})` : objectiveName, angle: -90, position: 'insideLeft', fill: '#94a3b8', fontSize: 12 }} /> { const label = name === 'value' ? 'Trial Value' : name === 'best' ? 'Running Best' : name; // Show original values in tooltip when using log scale if (useLogScale) { const original = name === 'value' ? props.payload.originalValue : props.payload.originalBest; return [original?.toFixed(4) ?? value.toFixed(4), label]; } return [value.toFixed(4), label]; }} labelFormatter={(label) => `Trial #${label}`} /> value === 'value' ? 'Trial Value' : 'Running Best'} /> {/* Area under trial values */} {/* Trial values as scatter points */} {/* Running best line */} {/* Reference line at best value */} {stats && ( )}
{/* Summary stats bar */} {stats && (
First Value
{stats.first.toFixed(4)}
Mean
{stats.mean.toFixed(4)}
Std Dev
{stats.std.toFixed(4)}
Total Trials
{stats.totalTrials}
)}
); }