Dashboard enhancements:
- Add Analysis page with tabs: Overview, Parameters, Pareto, Correlations, Constraints, Surrogate, Runs
- Add PlotlyCorrelationHeatmap for parameter-objective correlation analysis
- Add PlotlyFeasibilityChart for constraint satisfaction visualization
- Add PlotlySurrogateQuality for FEA vs NN prediction comparison
- Add PlotlyRunComparison for comparing optimization runs within a study
Real-time improvements:
- Replace watchdog file-watching with SQLite database polling for better Windows reliability
- Add DatabasePoller class with 2-second polling interval
- Enhanced WebSocket messages: trial_completed, new_best, pareto_update, progress
Desktop notifications:
- Add useNotifications hook using Web Notifications API
- Add NotificationSettings toggle component
- Notify users when new best solutions are found
Config editor:
- Add PUT /studies/{study_id}/config endpoint with auto-backup
- Add ConfigEditor modal with tabs: General, Variables, Objectives, Settings, JSON
- Prevents editing while optimization is running
Enhanced Pareto visualization:
- Add dark mode styling with transparent backgrounds
- Add stats bar showing Pareto, FEA, NN, and infeasible counts
- Add Pareto front connecting line for 2D view
- Add table showing top 10 Pareto-optimal solutions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
248 lines
9.1 KiB
TypeScript
248 lines
9.1 KiB
TypeScript
import { useMemo } from 'react';
|
|
import Plot from 'react-plotly.js';
|
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
|
|
|
interface Run {
|
|
run_id: number;
|
|
name: string;
|
|
source: 'FEA' | 'NN';
|
|
trial_count: number;
|
|
best_value: number | null;
|
|
avg_value: number | null;
|
|
first_trial: string | null;
|
|
last_trial: string | null;
|
|
}
|
|
|
|
interface PlotlyRunComparisonProps {
|
|
runs: Run[];
|
|
height?: number;
|
|
}
|
|
|
|
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
|
|
const chartData = useMemo(() => {
|
|
if (runs.length === 0) return null;
|
|
|
|
// Separate FEA and NN runs
|
|
const feaRuns = runs.filter(r => r.source === 'FEA');
|
|
const nnRuns = runs.filter(r => r.source === 'NN');
|
|
|
|
// Create bar chart for trial counts
|
|
const trialCountData = {
|
|
x: runs.map(r => r.name),
|
|
y: runs.map(r => r.trial_count),
|
|
type: 'bar' as const,
|
|
name: 'Trial Count',
|
|
marker: {
|
|
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
|
|
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
|
|
},
|
|
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
|
|
};
|
|
|
|
// Create line chart for best values
|
|
const bestValueData = {
|
|
x: runs.map(r => r.name),
|
|
y: runs.map(r => r.best_value),
|
|
type: 'scatter' as const,
|
|
mode: 'lines+markers' as const,
|
|
name: 'Best Value',
|
|
yaxis: 'y2',
|
|
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
|
|
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
|
|
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
|
|
};
|
|
|
|
return { trialCountData, bestValueData, feaRuns, nnRuns };
|
|
}, [runs]);
|
|
|
|
// Calculate statistics
|
|
const stats = useMemo(() => {
|
|
if (runs.length === 0) return null;
|
|
|
|
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
|
|
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
|
|
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
|
|
|
|
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
|
|
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
|
|
|
|
// Calculate improvement from first FEA run to overall best
|
|
const feaRuns = runs.filter(r => r.source === 'FEA');
|
|
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
|
|
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
|
|
|
|
return {
|
|
totalTrials,
|
|
feaTrials,
|
|
nnTrials,
|
|
overallBest,
|
|
improvement,
|
|
totalRuns: runs.length,
|
|
feaRuns: runs.filter(r => r.source === 'FEA').length,
|
|
nnRuns: runs.filter(r => r.source === 'NN').length
|
|
};
|
|
}, [runs]);
|
|
|
|
if (!chartData || !stats) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64 text-dark-400">
|
|
No run data available
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Stats Summary */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
<div className="bg-dark-750 rounded-lg p-3">
|
|
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
|
|
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
|
|
</div>
|
|
<div className="bg-dark-750 rounded-lg p-3">
|
|
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
|
|
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
|
|
</div>
|
|
<div className="bg-dark-750 rounded-lg p-3">
|
|
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
|
|
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
|
|
</div>
|
|
<div className="bg-dark-750 rounded-lg p-3">
|
|
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
|
|
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
|
|
</div>
|
|
<div className="bg-dark-750 rounded-lg p-3">
|
|
<div className="text-xs text-dark-400 mb-1">Best Value</div>
|
|
<div className="text-xl font-bold text-green-400">
|
|
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
|
|
</div>
|
|
</div>
|
|
<div className="bg-dark-750 rounded-lg p-3">
|
|
<div className="text-xs text-dark-400 mb-1">Improvement</div>
|
|
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
|
|
{stats.improvement !== null ? (
|
|
<>
|
|
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
|
|
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
|
|
<Minus className="w-4 h-4" />}
|
|
{Math.abs(stats.improvement).toFixed(1)}%
|
|
</>
|
|
) : 'N/A'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
<Plot
|
|
data={[chartData.trialCountData, chartData.bestValueData]}
|
|
layout={{
|
|
height,
|
|
margin: { l: 60, r: 60, t: 40, b: 100 },
|
|
paper_bgcolor: 'transparent',
|
|
plot_bgcolor: 'transparent',
|
|
font: { color: '#9ca3af', size: 11 },
|
|
showlegend: true,
|
|
legend: {
|
|
orientation: 'h',
|
|
y: 1.12,
|
|
x: 0.5,
|
|
xanchor: 'center',
|
|
bgcolor: 'transparent'
|
|
},
|
|
xaxis: {
|
|
tickangle: -45,
|
|
gridcolor: 'rgba(75, 85, 99, 0.3)',
|
|
linecolor: 'rgba(75, 85, 99, 0.5)',
|
|
tickfont: { size: 10 }
|
|
},
|
|
yaxis: {
|
|
title: { text: 'Trial Count' },
|
|
gridcolor: 'rgba(75, 85, 99, 0.3)',
|
|
linecolor: 'rgba(75, 85, 99, 0.5)',
|
|
zeroline: false
|
|
},
|
|
yaxis2: {
|
|
title: { text: 'Best Value' },
|
|
overlaying: 'y',
|
|
side: 'right',
|
|
gridcolor: 'rgba(75, 85, 99, 0.1)',
|
|
linecolor: 'rgba(75, 85, 99, 0.5)',
|
|
zeroline: false,
|
|
tickformat: '.2e'
|
|
},
|
|
bargap: 0.3,
|
|
hovermode: 'x unified'
|
|
}}
|
|
config={{
|
|
displayModeBar: true,
|
|
displaylogo: false,
|
|
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
|
|
}}
|
|
className="w-full"
|
|
useResizeHandler
|
|
style={{ width: '100%' }}
|
|
/>
|
|
|
|
{/* Runs Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-dark-600">
|
|
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
|
|
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
|
|
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
|
|
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
|
|
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
|
|
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{runs.map((run) => {
|
|
// Calculate duration if times available
|
|
let duration = '-';
|
|
if (run.first_trial && run.last_trial) {
|
|
const start = new Date(run.first_trial);
|
|
const end = new Date(run.last_trial);
|
|
const diffMs = end.getTime() - start.getTime();
|
|
const diffMins = Math.round(diffMs / 60000);
|
|
if (diffMins < 60) {
|
|
duration = `${diffMins}m`;
|
|
} else {
|
|
const hours = Math.floor(diffMins / 60);
|
|
const mins = diffMins % 60;
|
|
duration = `${hours}h ${mins}m`;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
|
|
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
|
|
<td className="py-2 px-3">
|
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
run.source === 'NN'
|
|
? 'bg-purple-500/20 text-purple-400'
|
|
: 'bg-blue-500/20 text-blue-400'
|
|
}`}>
|
|
{run.source}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
|
|
<td className="py-2 px-3 text-right font-mono text-green-400">
|
|
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
|
|
</td>
|
|
<td className="py-2 px-3 text-right font-mono text-dark-300">
|
|
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
|
|
</td>
|
|
<td className="py-2 px-3 text-dark-400">{duration}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PlotlyRunComparison;
|