Files
Atomizer/atomizer-dashboard/frontend/src/components/plotly/PlotlyConvergencePlot.tsx
Anto01 73a7b9d9f1 feat: Add dashboard chat integration and MCP server
Major changes:
- Dashboard: WebSocket-based chat with session management
- Dashboard: New chat components (ChatPane, ChatInput, ModeToggle)
- Dashboard: Enhanced UI with parallel coordinates chart
- MCP Server: New atomizer-tools server for Claude integration
- Extractors: Enhanced Zernike OPD extractor
- Reports: Improved report generator

New studies (configs and scripts only):
- M1 Mirror: Cost reduction campaign studies
- Simple Beam, Simple Bracket, UAV Arm studies

Note: Large iteration data (2_iterations/, best_design_archive/)
excluded via .gitignore - kept on local Gitea only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:53:55 -05:00

261 lines
7.3 KiB
TypeScript

/**
* 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<string, number>;
user_attrs?: Record<string, any>;
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}<br>${objectiveName}: ${val.toFixed(4)}<br>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 (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available
</div>
);
}
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 (
<div className="w-full">
{/* Summary stats and controls */}
<div className="flex items-center justify-between mb-3">
<div className="flex gap-6 text-sm">
<div className="text-gray-600">
Best: <span className="font-semibold text-green-600">{bestVal.toFixed(4)}</span>
<span className="text-gray-400 ml-1">(Trial #{bestTrial})</span>
</div>
<div className="text-gray-600">
Current: <span className="font-semibold">{allY[allY.length - 1].toFixed(4)}</span>
</div>
<div className="text-gray-600">
Trials: <span className="font-semibold">{allX.length}</span>
</div>
</div>
{/* Log scale toggle */}
{showLogScaleToggle && (
<button
onClick={() => setUseLogScale(!useLogScale)}
className={`px-3 py-1 text-xs rounded transition-colors ${
useLogScale
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
title="Toggle logarithmic scale - better for viewing early improvements"
>
{useLogScale ? 'Log Scale' : 'Linear Scale'}
</button>
)}
</div>
<Plot
data={traces}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'convergence_plot',
height: 600,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
</div>
);
}