feat: Add Zernike wavefront viewer and V14 TPE optimization study
Dashboard Zernike Analysis:
- Add ZernikeViewer component with tabbed UI (40°, 60°, 90° vs 20°)
- Generate 3D surface mesh plots with Mesh3d triangulation
- Full 50-mode Zernike coefficient tables with mode names
- Manufacturing metrics for 90_vs_20 (optician workload analysis)
- OP2 availability filter for FEA trials only
- Fix duplicate trial display with unique React keys
- Tab switching with proper event propagation
Backend API Enhancements:
- GET /studies/{id}/trials/{num}/zernike - Generate Zernike HTML on-demand
- GET /studies/{id}/zernike-available - List trials with OP2 files
- compute_manufacturing_metrics() for aberration analysis
- compute_rms_filter_j1to3() for optician workload metric
M1 Mirror V14 Study:
- TPE (Tree-structured Parzen Estimator) optimization
- Seeds from 496 prior FEA trials (V11+V12+V13)
- Weighted-sum objective: 5*obj_40 + 5*obj_60 + 1*obj_mfg
- Multivariate TPE with constant_liar for efficient exploration
- Ready for 8-hour overnight runs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
301
atomizer-dashboard/frontend/src/components/ZernikeViewer.tsx
Normal file
301
atomizer-dashboard/frontend/src/components/ZernikeViewer.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Zernike Viewer Component
|
||||
* Displays interactive Zernike wavefront analysis for mirror optimization trials
|
||||
*
|
||||
* Features:
|
||||
* - 3D surface residual plots (Plotly)
|
||||
* - RMS metrics tables
|
||||
* - Zernike coefficient bar charts
|
||||
* - Tab navigation for different angle comparisons (40°, 60°, 90° vs 20°)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, RefreshCw, Activity, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface ZernikeComparison {
|
||||
html: string;
|
||||
rms_global: number;
|
||||
rms_filtered: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ZernikeData {
|
||||
study_id: string;
|
||||
trial_number: number;
|
||||
comparisons: Record<string, ZernikeComparison>;
|
||||
available_comparisons: string[];
|
||||
}
|
||||
|
||||
interface ZernikeViewerProps {
|
||||
studyId: string;
|
||||
trialNumber: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ZernikeViewer({ studyId, trialNumber, onClose }: ZernikeViewerProps) {
|
||||
const [data, setData] = useState<ZernikeData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<string>('40_vs_20');
|
||||
|
||||
const fetchZernikeData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/optimization/studies/${studyId}/trials/${trialNumber}/zernike`
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
// Select first available tab
|
||||
if (result.available_comparisons?.length > 0) {
|
||||
setSelectedTab(result.available_comparisons[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load Zernike analysis');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchZernikeData();
|
||||
}, [studyId, trialNumber]);
|
||||
|
||||
// Tab labels for display
|
||||
const tabLabels: Record<string, string> = {
|
||||
'40_vs_20': '40° vs 20°',
|
||||
'60_vs_20': '60° vs 20°',
|
||||
'90_vs_20': '90° vs 20° (Mfg)',
|
||||
};
|
||||
|
||||
// Get current comparison data
|
||||
const currentComparison = data?.comparisons[selectedTab];
|
||||
|
||||
// Navigate between tabs
|
||||
const navigateTab = (direction: 'prev' | 'next') => {
|
||||
if (!data?.available_comparisons) return;
|
||||
const currentIndex = data.available_comparisons.indexOf(selectedTab);
|
||||
if (direction === 'prev' && currentIndex > 0) {
|
||||
setSelectedTab(data.available_comparisons[currentIndex - 1]);
|
||||
} else if (direction === 'next' && currentIndex < data.available_comparisons.length - 1) {
|
||||
setSelectedTab(data.available_comparisons[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Open in new window
|
||||
const openInNewWindow = () => {
|
||||
if (!currentComparison?.html) return;
|
||||
const newWindow = window.open('', '_blank');
|
||||
if (newWindow) {
|
||||
newWindow.document.write(currentComparison.html);
|
||||
newWindow.document.close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-dark-800 rounded-xl shadow-2xl w-[98vw] max-w-[1800px] h-[95vh] flex flex-col border border-dark-600"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-600 bg-dark-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="text-primary-400" size={24} />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
Zernike Analysis - Trial #{trialNumber}
|
||||
</h2>
|
||||
<p className="text-sm text-dark-400">{studyId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* RMS Quick Summary */}
|
||||
{currentComparison && (
|
||||
<div className="flex items-center gap-4 mr-4 px-4 py-2 bg-dark-600 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-dark-400">Global RMS</div>
|
||||
<div className="text-sm font-mono text-primary-300">
|
||||
{currentComparison.rms_global.toFixed(2)} nm
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center border-l border-dark-500 pl-4">
|
||||
<div className="text-xs text-dark-400">Filtered RMS</div>
|
||||
<div className="text-sm font-mono text-green-400">
|
||||
{currentComparison.rms_filtered.toFixed(2)} nm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchZernikeData}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={18} className={`text-dark-300 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={openInNewWindow}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
|
||||
title="Open in new window"
|
||||
disabled={!currentComparison}
|
||||
>
|
||||
<ExternalLink size={18} className="text-dark-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-dark-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{data?.available_comparisons && data.available_comparisons.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-2 border-b border-dark-600 bg-dark-700/30">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTab('prev');
|
||||
}}
|
||||
disabled={data.available_comparisons.indexOf(selectedTab) === 0}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={20} className="text-dark-300" />
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{data.available_comparisons.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setSelectedTab(tab);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedTab === tab
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-dark-600 text-dark-200 hover:bg-dark-500'
|
||||
}`}
|
||||
>
|
||||
{tabLabels[tab] || tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTab('next');
|
||||
}}
|
||||
disabled={data.available_comparisons.indexOf(selectedTab) === data.available_comparisons.length - 1}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg transition-colors disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={20} className="text-dark-300" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden bg-white">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-dark-700">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-400 mb-4"></div>
|
||||
<p className="text-dark-300">Generating Zernike analysis...</p>
|
||||
<p className="text-dark-500 text-sm mt-2">This may take a few seconds for large meshes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-dark-400 bg-dark-700">
|
||||
<Activity size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium text-yellow-400 mb-2">
|
||||
{error.includes('surrogate') || error.includes('NN') ? 'No FEA Results' : 'Analysis Failed'}
|
||||
</p>
|
||||
<p className="text-dark-300 mb-4 max-w-lg text-center">{error}</p>
|
||||
{error.includes('surrogate') || error.includes('NN') ? (
|
||||
<p className="text-dark-500 text-sm mb-4">
|
||||
Try selecting a trial with "FEA" source tag instead of "NN"
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={fetchZernikeData}
|
||||
className="px-4 py-2 text-sm bg-dark-600 hover:bg-dark-500 text-dark-200 rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-4 py-2 text-sm bg-dark-700 hover:bg-dark-600 text-dark-300 rounded-lg transition-colors border border-dark-500"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && currentComparison && (
|
||||
<iframe
|
||||
srcDoc={currentComparison.html}
|
||||
className="w-full h-full border-0"
|
||||
title={`Zernike Analysis - ${tabLabels[selectedTab] || selectedTab}`}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component to trigger Zernike viewer for a trial
|
||||
* Used in Dashboard trial list
|
||||
*/
|
||||
interface ZernikeButtonProps {
|
||||
studyId: string;
|
||||
trialNumber: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ZernikeButton({ studyId, trialNumber, compact = false }: ZernikeButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent trial row expansion
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className={`flex items-center gap-1 ${
|
||||
compact
|
||||
? 'px-2 py-1 text-xs'
|
||||
: 'px-3 py-1.5 text-sm'
|
||||
} bg-indigo-600 hover:bg-indigo-700 text-white rounded transition-colors font-medium`}
|
||||
title="View Zernike wavefront analysis"
|
||||
>
|
||||
<Activity size={compact ? 12 : 14} />
|
||||
<span>{compact ? 'Zernike' : 'View Zernike'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<ZernikeViewer
|
||||
studyId={studyId}
|
||||
trialNumber={trialNumber}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { ParameterImportanceChart } from '../components/ParameterImportanceChart
|
||||
import { ConvergencePlot } from '../components/ConvergencePlot';
|
||||
import { StudyReportViewer } from '../components/StudyReportViewer';
|
||||
import { ConsoleOutput } from '../components/ConsoleOutput';
|
||||
import { ZernikeButton } from '../components/ZernikeViewer';
|
||||
import { ExpandableChart } from '../components/ExpandableChart';
|
||||
import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
|
||||
import type { Trial } from '../types';
|
||||
@@ -49,6 +50,8 @@ export default function Dashboard() {
|
||||
const [sortBy, setSortBy] = useState<'performance' | 'chronological'>('performance');
|
||||
const [trialsPage, setTrialsPage] = useState(0);
|
||||
const trialsPerPage = 50; // Limit trials per page for performance
|
||||
const [showOnlyFEA, setShowOnlyFEA] = useState(false); // Filter to show only trials with OP2 results
|
||||
const [zernikeAvailableTrials, setZernikeAvailableTrials] = useState<Set<number>>(new Set()); // Trials with OP2 files
|
||||
|
||||
// Parameter Space axis selection (reserved for future use)
|
||||
const [_paramXIndex, _setParamXIndex] = useState(0);
|
||||
@@ -98,7 +101,12 @@ export default function Dashboard() {
|
||||
onMessage: (msg) => {
|
||||
if (msg.type === 'trial_completed') {
|
||||
const trial = msg.data as Trial;
|
||||
setAllTrials(prev => [...prev, trial]);
|
||||
// Avoid duplicates by checking if trial already exists
|
||||
setAllTrials(prev => {
|
||||
const exists = prev.some(t => t.trial_number === trial.trial_number);
|
||||
if (exists) return prev;
|
||||
return [...prev, trial];
|
||||
});
|
||||
if (trial.objective !== null && trial.objective !== undefined && trial.objective < bestValue) {
|
||||
const improvement = previousBestRef.current !== Infinity
|
||||
? ((previousBestRef.current - trial.objective) / Math.abs(previousBestRef.current)) * 100
|
||||
@@ -199,6 +207,18 @@ export default function Dashboard() {
|
||||
setIsRunning(data.is_running);
|
||||
})
|
||||
.catch(err => console.error('Failed to load process status:', err));
|
||||
|
||||
// Fetch available Zernike trials (for mirror/zernike studies)
|
||||
if (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) {
|
||||
fetch(`/api/optimization/studies/${selectedStudyId}/zernike-available`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setZernikeAvailableTrials(new Set(data.available_trials || []));
|
||||
})
|
||||
.catch(err => console.error('Failed to load Zernike available trials:', err));
|
||||
} else {
|
||||
setZernikeAvailableTrials(new Set());
|
||||
}
|
||||
}
|
||||
}, [selectedStudyId]);
|
||||
|
||||
@@ -217,22 +237,30 @@ export default function Dashboard() {
|
||||
return () => clearInterval(pollStatus);
|
||||
}, [selectedStudyId]);
|
||||
|
||||
// Sort trials based on selected sort order
|
||||
// Sort and filter trials based on selected options
|
||||
useEffect(() => {
|
||||
let sorted = [...allTrials];
|
||||
let filtered = [...allTrials];
|
||||
|
||||
// Filter to trials with OP2 results (for Zernike analysis)
|
||||
if (showOnlyFEA && zernikeAvailableTrials.size > 0) {
|
||||
filtered = filtered.filter(t => zernikeAvailableTrials.has(t.trial_number));
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy === 'performance') {
|
||||
// Sort by objective (best first)
|
||||
sorted.sort((a, b) => {
|
||||
filtered.sort((a, b) => {
|
||||
const aObj = a.objective ?? Infinity;
|
||||
const bObj = b.objective ?? Infinity;
|
||||
return aObj - bObj;
|
||||
});
|
||||
} else {
|
||||
// Chronological (newest first)
|
||||
sorted.sort((a, b) => b.trial_number - a.trial_number);
|
||||
filtered.sort((a, b) => b.trial_number - a.trial_number);
|
||||
}
|
||||
setDisplayedTrials(sorted);
|
||||
}, [allTrials, sortBy]);
|
||||
setDisplayedTrials(filtered);
|
||||
setTrialsPage(0); // Reset pagination when filter changes
|
||||
}, [allTrials, sortBy, showOnlyFEA, zernikeAvailableTrials]);
|
||||
|
||||
// Auto-refresh polling for trial history
|
||||
// PERFORMANCE: Use limit and longer interval for large studies
|
||||
@@ -649,8 +677,22 @@ export default function Dashboard() {
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>Trial History ({displayedTrials.length} trials)</span>
|
||||
<span>Trial History ({displayedTrials.length}{showOnlyFEA ? ' with OP2' : ''} trials{showOnlyFEA && allTrials.length !== displayedTrials.length ? ` of ${allTrials.length}` : ''})</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* OP2/Zernike toggle - only show for mirror/zernike studies */}
|
||||
{selectedStudyId && (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) && zernikeAvailableTrials.size > 0 && (
|
||||
<button
|
||||
onClick={() => setShowOnlyFEA(!showOnlyFEA)}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
showOnlyFEA
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-dark-500 text-dark-200 hover:bg-dark-400'
|
||||
}`}
|
||||
title={`Show only ${zernikeAvailableTrials.size} trials with OP2 results (for Zernike analysis)`}
|
||||
>
|
||||
OP2 Only ({zernikeAvailableTrials.size})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSortBy('performance')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
@@ -699,13 +741,13 @@ export default function Dashboard() {
|
||||
>
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{displayedTrials.length > 0 ? (
|
||||
displayedTrials.slice(trialsPage * trialsPerPage, (trialsPage + 1) * trialsPerPage).map(trial => {
|
||||
displayedTrials.slice(trialsPage * trialsPerPage, (trialsPage + 1) * trialsPerPage).map((trial, idx) => {
|
||||
const isExpanded = expandedTrials.has(trial.trial_number);
|
||||
const isBest = trial.objective === bestValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trial.trial_number}
|
||||
key={`trial-${trial.trial_number}-${idx}`}
|
||||
className={`rounded-lg transition-all duration-200 cursor-pointer ${
|
||||
isBest
|
||||
? 'bg-green-900 border-l-4 border-green-400'
|
||||
@@ -728,6 +770,14 @@ export default function Dashboard() {
|
||||
? trial.objective.toFixed(4)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
{/* Zernike viewer button - only show for mirror/Zernike studies */}
|
||||
{selectedStudyId && (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) && (
|
||||
<ZernikeButton
|
||||
studyId={selectedStudyId}
|
||||
trialNumber={trial.trial_number}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
<span className="text-dark-400 text-sm">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
@@ -829,6 +879,16 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zernike Analysis Button - Full size in expanded view */}
|
||||
{selectedStudyId && (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) && (
|
||||
<div className="border-t border-dark-400 pt-3 mt-3">
|
||||
<ZernikeButton
|
||||
studyId={selectedStudyId}
|
||||
trialNumber={trial.trial_number}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user