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>
261 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|