Files
Atomizer/optimization_engine/insights/zernike_dashboard.py

1305 lines
46 KiB
Python
Raw Normal View History

"""
Zernike Dashboard - Comprehensive Mirror Wavefront Analysis
A complete, professional dashboard for M1 mirror optimization that combines:
- Executive summary with all key metrics at a glance
- All orientation views (40°, 60°, 90°) on a single page
- MSF band analysis integrated
- Light/white theme for better readability
- Interactive comparison between subcases
This is the unified replacement for the separate zernike_wfe and msf_zernike insights.
Author: Atomizer Framework
Date: 2024-12-22
"""
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple
import numpy as np
from math import factorial
from numpy.linalg import LinAlgError
from .base import StudyInsight, InsightConfig, InsightResult, register_insight
# Lazy imports
_plotly_loaded = False
_go = None
_make_subplots = None
_Triangulation = None
_OP2 = None
_BDF = None
_ZernikeOPDExtractor = None
def _load_dependencies():
"""Lazy load heavy dependencies."""
global _plotly_loaded, _go, _make_subplots, _Triangulation, _OP2, _BDF, _ZernikeOPDExtractor
if not _plotly_loaded:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from matplotlib.tri import Triangulation
from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF
from ..extractors.extract_zernike_figure import ZernikeOPDExtractor
_go = go
_make_subplots = make_subplots
_Triangulation = Triangulation
_OP2 = OP2
_BDF = BDF
_ZernikeOPDExtractor = ZernikeOPDExtractor
_plotly_loaded = True
# ============================================================================
# Color Themes
# ============================================================================
LIGHT_THEME = {
'bg': '#ffffff',
'card_bg': '#f8fafc',
'card_border': '#e2e8f0',
'text': '#1e293b',
'text_muted': '#64748b',
'accent': '#3b82f6',
'success': '#10b981',
'warning': '#f59e0b',
'danger': '#ef4444',
'grid': 'rgba(100,116,139,0.2)',
'surface_bg': '#f1f5f9',
}
BAND_COLORS = {
'lsf': '#3b82f6', # Blue
'msf': '#f59e0b', # Amber
'hsf': '#ef4444', # Red
}
ANGLE_COLORS = {
'40': '#10b981', # Emerald
'60': '#3b82f6', # Blue
'90': '#8b5cf6', # Purple
}
# ============================================================================
# Zernike Mathematics
# ============================================================================
def noll_indices(j: int) -> Tuple[int, int]:
"""Convert Noll index to (n, m) radial/azimuthal orders."""
if j < 1:
raise ValueError("Noll index j must be >= 1")
count = 0
n = 0
while True:
if n == 0:
ms = [0]
elif n % 2 == 0:
ms = [0] + [m for k in range(1, n//2 + 1) for m in (-2*k, 2*k)]
else:
ms = [m for k in range(0, (n+1)//2) for m in (-(2*k+1), (2*k+1))]
for m in ms:
count += 1
if count == j:
return n, m
n += 1
def noll_to_radial_order(j: int) -> int:
"""Get radial order n for Noll index j."""
n, _ = noll_indices(j)
return n
def zernike_noll(j: int, r: np.ndarray, th: np.ndarray) -> np.ndarray:
"""Evaluate Zernike polynomial j at (r, theta)."""
n, m = noll_indices(j)
R = np.zeros_like(r)
for s in range((n - abs(m)) // 2 + 1):
c = ((-1)**s * factorial(n - s) /
(factorial(s) *
factorial((n + abs(m)) // 2 - s) *
factorial((n - abs(m)) // 2 - s)))
R += c * r**(n - 2*s)
if m == 0:
return R
return R * (np.cos(m * th) if m > 0 else np.sin(-m * th))
def zernike_common_name(n: int, m: int) -> str:
"""Get common name for Zernike mode."""
names = {
(0, 0): "Piston", (1, -1): "Tilt X", (1, 1): "Tilt Y",
(2, 0): "Defocus", (2, -2): "Astig 45°", (2, 2): "Astig 0°",
(3, -1): "Coma X", (3, 1): "Coma Y", (3, -3): "Trefoil X", (3, 3): "Trefoil Y",
(4, 0): "Spherical", (4, -2): "Sec Astig X", (4, 2): "Sec Astig Y",
(4, -4): "Quadrafoil X", (4, 4): "Quadrafoil Y",
(5, -1): "Sec Coma X", (5, 1): "Sec Coma Y",
(6, 0): "Sec Spherical",
}
return names.get((n, m), f"Z({n},{m})")
def compute_zernike_coeffs(
X: np.ndarray,
Y: np.ndarray,
vals: np.ndarray,
n_modes: int,
chunk_size: int = 100000
) -> Tuple[np.ndarray, float]:
"""Fit Zernike coefficients to WFE data."""
Xc, Yc = X - np.mean(X), Y - np.mean(Y)
R = float(np.max(np.hypot(Xc, Yc)))
r = np.hypot(Xc / R, Yc / R).astype(np.float32)
th = np.arctan2(Yc, Xc).astype(np.float32)
mask = (r <= 1.0) & ~np.isnan(vals)
if not np.any(mask):
raise RuntimeError("No valid points inside unit disk.")
idx = np.nonzero(mask)[0]
m = int(n_modes)
G = np.zeros((m, m), dtype=np.float64)
h = np.zeros((m,), dtype=np.float64)
v = vals.astype(np.float64)
for start in range(0, len(idx), chunk_size):
sl = idx[start:start + chunk_size]
r_b, th_b, v_b = r[sl], th[sl], v[sl]
Zb = np.column_stack([zernike_noll(j, r_b, th_b).astype(np.float32)
for j in range(1, m + 1)])
G += (Zb.T @ Zb).astype(np.float64)
h += (Zb.T @ v_b).astype(np.float64)
try:
coeffs = np.linalg.solve(G, h)
except LinAlgError:
coeffs = np.linalg.lstsq(G, h, rcond=None)[0]
return coeffs, R
def classify_modes_by_band(
n_modes: int,
lsf_max: int = 10,
msf_max: int = 50
) -> Dict[str, List[int]]:
"""Classify Zernike modes into LSF/MSF/HSF bands."""
bands = {'lsf': [], 'msf': [], 'hsf': []}
for j in range(1, n_modes + 1):
n = noll_to_radial_order(j)
if n <= lsf_max:
bands['lsf'].append(j)
elif n <= msf_max:
bands['msf'].append(j)
else:
bands['hsf'].append(j)
return bands
def compute_band_rss(coeffs: np.ndarray, bands: Dict[str, List[int]]) -> Dict[str, float]:
"""Compute RSS (Root Sum Square) of coefficients in each band."""
rss = {}
for band_name, indices in bands.items():
band_coeffs = [coeffs[j-1] for j in indices if j-1 < len(coeffs)]
rss[band_name] = float(np.sqrt(np.sum(np.array(band_coeffs)**2)))
rss['total'] = float(np.sqrt(np.sum(coeffs**2)))
# Filtered: exclude J1-J4 (piston, tip, tilt, defocus)
filtered_coeffs = coeffs[4:] if len(coeffs) > 4 else np.array([])
rss['filtered'] = float(np.sqrt(np.sum(filtered_coeffs**2)))
return rss
# ============================================================================
# Default Configuration
# ============================================================================
DEFAULT_CONFIG = {
'n_modes': 50,
'amp': 0.5,
'pancake': 3.0,
'plot_downsample': 8000,
'filter_low_orders': 4,
'lsf_max': 10,
'msf_max': 50,
'disp_unit': 'mm',
'use_opd': True, # Use OPD method by default
}
@register_insight
class ZernikeDashboardInsight(StudyInsight):
"""
Comprehensive Zernike Dashboard for M1 Mirror Optimization.
Generates a single-page dashboard with:
- Executive summary with key metrics
- All orientations (40°, 60°, 90°) in comparison view
- MSF band analysis
- Interactive surface plots
- Professional light theme
"""
insight_type = "zernike_dashboard"
name = "Zernike Dashboard"
description = "Comprehensive mirror WFE dashboard with all orientations and MSF analysis"
category = "optical"
applicable_to = ["mirror", "optics", "wfe"]
required_files = ["*.op2"]
def __init__(self, study_path: Path):
super().__init__(study_path)
self.op2_path: Optional[Path] = None
self.geo_path: Optional[Path] = None
self._node_geo: Optional[Dict] = None
self._displacements: Optional[Dict] = None
def can_generate(self) -> bool:
"""Check if OP2 and geometry files exist."""
search_paths = [
self.results_path,
self.study_path / "2_iterations",
self.setup_path / "model",
]
for search_path in search_paths:
if not search_path.exists():
continue
op2_files = list(search_path.glob("**/*solution*.op2"))
if not op2_files:
op2_files = list(search_path.glob("**/*.op2"))
if op2_files:
self.op2_path = max(op2_files, key=lambda p: p.stat().st_mtime)
break
if self.op2_path is None:
return False
try:
self.geo_path = self._find_geometry_file(self.op2_path)
return True
except FileNotFoundError:
return False
def _find_geometry_file(self, op2_path: Path) -> Path:
"""Find BDF/DAT geometry file for OP2."""
folder = op2_path.parent
base = op2_path.stem
for ext in ['.dat', '.bdf']:
cand = folder / (base + ext)
if cand.exists():
return cand
for f in folder.iterdir():
if f.suffix.lower() in ['.dat', '.bdf']:
return f
raise FileNotFoundError(f"No geometry file found for {op2_path}")
def _load_data(self):
"""Load geometry and displacement data."""
if self._node_geo is not None:
return
_load_dependencies()
bdf = _BDF()
bdf.read_bdf(str(self.geo_path))
self._node_geo = {int(nid): node.get_position()
for nid, node in bdf.nodes.items()}
op2 = _OP2()
op2.read_op2(str(self.op2_path))
if not op2.displacements:
raise RuntimeError("No displacement data in OP2")
self._displacements = {}
for key, darr in op2.displacements.items():
data = darr.data
dmat = data[0] if data.ndim == 3 else (data if data.ndim == 2 else None)
if dmat is None:
continue
ngt = darr.node_gridtype.astype(int)
node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]
isubcase = getattr(darr, 'isubcase', None)
label = str(isubcase) if isubcase else str(key)
self._displacements[label] = {
'node_ids': node_ids.astype(int),
'disp': dmat.copy()
}
def _build_wfe_arrays_standard(
self,
label: str,
disp_unit: str = 'mm'
) -> Dict[str, np.ndarray]:
"""Build arrays using standard Z-only method."""
nm_per_unit = 1e6 if disp_unit == 'mm' else 1e9
data = self._displacements[label]
node_ids = data['node_ids']
dmat = data['disp']
X, Y, WFE = [], [], []
valid_nids = []
dx_arr, dy_arr, dz_arr = [], [], []
for nid, vec in zip(node_ids, dmat):
geo = self._node_geo.get(int(nid))
if geo is None:
continue
X.append(geo[0])
Y.append(geo[1])
wfe = vec[2] * 2.0 * nm_per_unit
WFE.append(wfe)
valid_nids.append(nid)
dx_arr.append(vec[0])
dy_arr.append(vec[1])
dz_arr.append(vec[2])
return {
'X': np.array(X),
'Y': np.array(Y),
'WFE': np.array(WFE),
'node_ids': np.array(valid_nids),
'dx': np.array(dx_arr),
'dy': np.array(dy_arr),
'dz': np.array(dz_arr),
'lateral_disp': np.sqrt(np.array(dx_arr)**2 + np.array(dy_arr)**2),
}
def _build_wfe_arrays_opd(
self,
label: str,
disp_unit: str = 'mm'
) -> Dict[str, np.ndarray]:
"""Build arrays using OPD method (recommended)."""
_load_dependencies()
extractor = _ZernikeOPDExtractor(
self.op2_path,
bdf_path=self.geo_path,
displacement_unit=disp_unit
)
opd_data = extractor._build_figure_opd_data(label)
return {
'X': opd_data['x_deformed'],
'Y': opd_data['y_deformed'],
'WFE': opd_data['wfe_nm'],
'node_ids': opd_data['node_ids'],
'dx': opd_data['dx'],
'dy': opd_data['dy'],
'dz': opd_data['dz'],
'lateral_disp': opd_data['lateral_disp'],
'x_original': opd_data['x_original'],
'y_original': opd_data['y_original'],
}
def _build_wfe_arrays(
self,
label: str,
disp_unit: str = 'mm',
use_opd: bool = True
) -> Dict[str, np.ndarray]:
"""Build X, Y, WFE arrays for a subcase."""
if use_opd:
return self._build_wfe_arrays_opd(label, disp_unit)
else:
return self._build_wfe_arrays_standard(label, disp_unit)
def _compute_relative_wfe(
self,
data1: Dict[str, np.ndarray],
data2: Dict[str, np.ndarray]
) -> Dict[str, np.ndarray]:
"""Compute relative WFE (target - reference) for common nodes."""
X1, Y1, WFE1, nids1 = data1['X'], data1['Y'], data1['WFE'], data1['node_ids']
X2, Y2, WFE2, nids2 = data2['X'], data2['Y'], data2['WFE'], data2['node_ids']
ref_map = {int(nid): (x, y, w) for nid, x, y, w in zip(nids2, X2, Y2, WFE2)}
dx1_map = {int(nid): d for nid, d in zip(data1['node_ids'], data1['dx'])}
dy1_map = {int(nid): d for nid, d in zip(data1['node_ids'], data1['dy'])}
dz1_map = {int(nid): d for nid, d in zip(data1['node_ids'], data1['dz'])}
dx2_map = {int(nid): d for nid, d in zip(data2['node_ids'], data2['dx'])}
dy2_map = {int(nid): d for nid, d in zip(data2['node_ids'], data2['dy'])}
dz2_map = {int(nid): d for nid, d in zip(data2['node_ids'], data2['dz'])}
X_rel, Y_rel, WFE_rel, nids_rel = [], [], [], []
dx_rel, dy_rel, dz_rel = [], [], []
for nid, x, y, w in zip(nids1, X1, Y1, WFE1):
nid = int(nid)
if nid not in ref_map or nid not in dx2_map:
continue
_, _, w_ref = ref_map[nid]
X_rel.append(x)
Y_rel.append(y)
WFE_rel.append(w - w_ref)
nids_rel.append(nid)
dx_rel.append(dx1_map[nid] - dx2_map[nid])
dy_rel.append(dy1_map[nid] - dy2_map[nid])
dz_rel.append(dz1_map[nid] - dz2_map[nid])
return {
'X': np.array(X_rel),
'Y': np.array(Y_rel),
'WFE': np.array(WFE_rel),
'node_ids': np.array(nids_rel),
'dx': np.array(dx_rel),
'dy': np.array(dy_rel),
'dz': np.array(dz_rel),
'lateral_disp': np.sqrt(np.array(dx_rel)**2 + np.array(dy_rel)**2) if dx_rel else np.array([]),
}
def _compute_metrics(
self,
X: np.ndarray,
Y: np.ndarray,
W_nm: np.ndarray,
n_modes: int,
filter_orders: int,
cfg: Dict
) -> Dict[str, Any]:
"""Compute comprehensive metrics including band analysis."""
coeffs, R = compute_zernike_coeffs(X, Y, W_nm, n_modes)
Xc = X - np.mean(X)
Yc = Y - np.mean(Y)
r = np.hypot(Xc / R, Yc / R)
th = np.arctan2(Yc, Xc)
Z = np.column_stack([zernike_noll(j, r, th) for j in range(1, n_modes + 1)])
W_res_filt = W_nm - Z[:, :filter_orders].dot(coeffs[:filter_orders])
W_res_filt_j1to3 = W_nm - Z[:, :3].dot(coeffs[:3])
# Band analysis
bands = classify_modes_by_band(n_modes, cfg['lsf_max'], cfg['msf_max'])
band_rss = compute_band_rss(coeffs, bands)
# Aberration magnitudes
aberrations = {
'defocus': float(abs(coeffs[3])) if len(coeffs) > 3 else 0.0,
'astigmatism': float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)) if len(coeffs) > 5 else 0.0,
'coma': float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)) if len(coeffs) > 7 else 0.0,
'trefoil': float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) if len(coeffs) > 9 else 0.0,
'spherical': float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0,
}
return {
'coefficients': coeffs,
'R': R,
'global_rms': float(np.sqrt(np.mean(W_nm**2))),
'filtered_rms': float(np.sqrt(np.mean(W_res_filt**2))),
'rms_j1to3': float(np.sqrt(np.mean(W_res_filt_j1to3**2))),
'W_res_filt': W_res_filt,
'band_rss': band_rss,
'aberrations': aberrations,
}
def _generate_html(
self,
all_data: Dict[str, Dict],
cfg: Dict,
timestamp: str
) -> str:
"""Generate comprehensive HTML dashboard."""
_load_dependencies()
theme = LIGHT_THEME
# Extract key metrics for executive summary
metrics_40 = all_data.get('40_vs_20', {}).get('metrics', {})
metrics_60 = all_data.get('60_vs_20', {}).get('metrics', {})
metrics_90 = all_data.get('90_abs', {}).get('metrics', {})
rms_40 = metrics_40.get('filtered_rms', 0)
rms_60 = metrics_60.get('filtered_rms', 0)
rms_90_mfg = metrics_90.get('rms_j1to3', 0)
# Generate HTML
html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zernike Dashboard - Atomizer</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: {theme['bg']};
color: {theme['text']};
line-height: 1.5;
}}
.dashboard {{
max-width: 1600px;
margin: 0 auto;
padding: 24px;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid {theme['card_border']};
}}
.header h1 {{
font-size: 28px;
font-weight: 700;
color: {theme['accent']};
}}
.header .subtitle {{
color: {theme['text_muted']};
font-size: 14px;
}}
.timestamp {{
color: {theme['text_muted']};
font-size: 12px;
}}
/* Executive Summary */
.summary-grid {{
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}}
.metric-card {{
background: {theme['card_bg']};
border: 1px solid {theme['card_border']};
border-radius: 12px;
padding: 20px;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
}}
.metric-card:hover {{
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}}
.metric-card.best {{
border-color: {theme['success']};
background: linear-gradient(to bottom, #ecfdf5, {theme['card_bg']});
}}
.metric-card.warning {{
border-color: {theme['warning']};
background: linear-gradient(to bottom, #fffbeb, {theme['card_bg']});
}}
.metric-label {{
font-size: 12px;
font-weight: 600;
color: {theme['text_muted']};
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}}
.metric-value {{
font-size: 32px;
font-weight: 700;
color: {theme['text']};
font-variant-numeric: tabular-nums;
}}
.metric-value.success {{
color: {theme['success']};
}}
.metric-value.warning {{
color: {theme['warning']};
}}
.metric-value.danger {{
color: {theme['danger']};
}}
.metric-unit {{
font-size: 14px;
color: {theme['text_muted']};
font-weight: 400;
}}
.metric-target {{
font-size: 11px;
color: {theme['text_muted']};
margin-top: 4px;
}}
/* Section Cards */
.section {{
background: {theme['card_bg']};
border: 1px solid {theme['card_border']};
border-radius: 12px;
margin-bottom: 24px;
overflow: hidden;
}}
.section-header {{
padding: 16px 20px;
background: {theme['surface_bg']};
border-bottom: 1px solid {theme['card_border']};
display: flex;
justify-content: space-between;
align-items: center;
}}
.section-title {{
font-size: 16px;
font-weight: 600;
color: {theme['text']};
}}
.section-subtitle {{
font-size: 12px;
color: {theme['text_muted']};
}}
.section-content {{
padding: 20px;
}}
/* Comparison Table */
.comparison-table {{
width: 100%;
border-collapse: collapse;
font-size: 14px;
}}
.comparison-table th {{
text-align: left;
padding: 12px 16px;
background: {theme['surface_bg']};
border-bottom: 1px solid {theme['card_border']};
font-weight: 600;
color: {theme['text']};
}}
.comparison-table td {{
padding: 12px 16px;
border-bottom: 1px solid {theme['card_border']};
}}
.comparison-table tr:last-child td {{
border-bottom: none;
}}
.comparison-table tr:hover {{
background: {theme['surface_bg']};
}}
.value-cell {{
font-variant-numeric: tabular-nums;
font-weight: 500;
}}
.value-cell.pass {{
color: {theme['success']};
}}
.value-cell.warn {{
color: {theme['warning']};
}}
.value-cell.fail {{
color: {theme['danger']};
}}
/* Tab Navigation */
.tab-nav {{
display: flex;
gap: 8px;
margin-bottom: 16px;
}}
.tab-btn {{
padding: 8px 16px;
background: {theme['surface_bg']};
border: 1px solid {theme['card_border']};
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: {theme['text_muted']};
transition: all 0.2s;
}}
.tab-btn:hover {{
background: {theme['card_border']};
}}
.tab-btn.active {{
background: {theme['accent']};
color: white;
border-color: {theme['accent']};
}}
/* Plot Container */
.plot-container {{
border-radius: 8px;
overflow: hidden;
}}
/* Band Legend */
.band-legend {{
display: flex;
gap: 24px;
justify-content: center;
padding: 12px;
background: {theme['surface_bg']};
border-radius: 8px;
margin-top: 16px;
}}
.band-item {{
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}}
.band-dot {{
width: 12px;
height: 12px;
border-radius: 50%;
}}
/* Grid layouts */
.plots-grid {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}}
.plot-card {{
background: {theme['bg']};
border: 1px solid {theme['card_border']};
border-radius: 8px;
overflow: hidden;
}}
.plot-header {{
padding: 12px 16px;
background: {theme['surface_bg']};
border-bottom: 1px solid {theme['card_border']};
font-weight: 600;
font-size: 14px;
text-align: center;
}}
.plot-header.angle-40 {{ color: {ANGLE_COLORS['40']}; }}
.plot-header.angle-60 {{ color: {ANGLE_COLORS['60']}; }}
.plot-header.angle-90 {{ color: {ANGLE_COLORS['90']}; }}
/* Responsive */
@media (max-width: 1200px) {{
.summary-grid {{
grid-template-columns: repeat(2, 1fr);
}}
.plots-grid {{
grid-template-columns: 1fr;
}}
}}
/* Print styles */
@media print {{
.dashboard {{
max-width: 100%;
}}
}}
</style>
</head>
<body>
<div class="dashboard">
<!-- Header -->
<div class="header">
<div>
<h1>Zernike WFE Dashboard</h1>
<div class="subtitle">M1 Mirror Optimization Analysis OPD Method (X,Y,Z corrected)</div>
</div>
<div class="timestamp">Generated: {timestamp}</div>
</div>
<!-- Executive Summary -->
<div class="summary-grid">
<div class="metric-card{' best' if rms_40 <= 4.0 else ' warning' if rms_40 <= 6.0 else ''}">
<div class="metric-label">40° vs 20° RMS</div>
<div class="metric-value{' success' if rms_40 <= 4.0 else ' warning' if rms_40 <= 6.0 else ' danger'}">
{rms_40:.2f}<span class="metric-unit"> nm</span>
</div>
<div class="metric-target">Target: 4.0 nm</div>
</div>
<div class="metric-card{' best' if rms_60 <= 10.0 else ' warning' if rms_60 <= 15.0 else ''}">
<div class="metric-label">60° vs 20° RMS</div>
<div class="metric-value{' success' if rms_60 <= 10.0 else ' warning' if rms_60 <= 15.0 else ' danger'}">
{rms_60:.2f}<span class="metric-unit"> nm</span>
</div>
<div class="metric-target">Target: 10.0 nm</div>
</div>
<div class="metric-card{' best' if rms_90_mfg <= 20.0 else ' warning' if rms_90_mfg <= 30.0 else ''}">
<div class="metric-label">90° MFG (J1-J3)</div>
<div class="metric-value{' success' if rms_90_mfg <= 20.0 else ' warning' if rms_90_mfg <= 30.0 else ' danger'}">
{rms_90_mfg:.2f}<span class="metric-unit"> nm</span>
</div>
<div class="metric-target">Target: 20.0 nm</div>
</div>
<div class="metric-card">
<div class="metric-label">Weighted Score</div>
<div class="metric-value">
{self._compute_weighted_score(rms_40, rms_60, rms_90_mfg):.1f}
</div>
<div class="metric-target">Lower is better</div>
</div>
</div>
<!-- Detailed Metrics Table -->
<div class="section">
<div class="section-header">
<div>
<div class="section-title">Detailed Metrics Comparison</div>
<div class="section-subtitle">All values in nm J1-J4 filtered (piston, tip, tilt, defocus removed)</div>
</div>
</div>
<div class="section-content">
<table class="comparison-table">
<thead>
<tr>
<th>Metric</th>
<th style="text-align:center">40° vs 20°</th>
<th style="text-align:center">60° vs 20°</th>
<th style="text-align:center">90° MFG</th>
<th style="text-align:center">Target</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Filtered RMS (J1-J4)</strong></td>
<td class="value-cell{self._get_status_class(rms_40, 4.0, 6.0)}" style="text-align:center">{rms_40:.2f}</td>
<td class="value-cell{self._get_status_class(rms_60, 10.0, 15.0)}" style="text-align:center">{rms_60:.2f}</td>
<td class="value-cell" style="text-align:center"></td>
<td style="text-align:center;color:{theme['text_muted']}"> 4 / 10 nm</td>
</tr>
<tr>
<td><strong>Optician Workload (J1-J3)</strong></td>
<td class="value-cell" style="text-align:center"></td>
<td class="value-cell" style="text-align:center"></td>
<td class="value-cell{self._get_status_class(rms_90_mfg, 20.0, 30.0)}" style="text-align:center">{rms_90_mfg:.2f}</td>
<td style="text-align:center;color:{theme['text_muted']}"> 20 nm</td>
</tr>
<tr>
<td>Astigmatism (J5+J6)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('astigmatism', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('astigmatism', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('astigmatism', 0):.2f}</td>
<td style="text-align:center;color:{theme['text_muted']}"></td>
</tr>
<tr>
<td>Coma (J7+J8)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('coma', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('coma', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('coma', 0):.2f}</td>
<td style="text-align:center;color:{theme['text_muted']}"></td>
</tr>
<tr>
<td>Trefoil (J9+J10)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('trefoil', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('trefoil', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('trefoil', 0):.2f}</td>
<td style="text-align:center;color:{theme['text_muted']}"></td>
</tr>
<tr>
<td>Spherical (J11)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('spherical', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('spherical', 0):.2f}</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('spherical', 0):.2f}</td>
<td style="text-align:center;color:{theme['text_muted']}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- MSF Band Analysis -->
<div class="section">
<div class="section-header">
<div>
<div class="section-title">Spatial Frequency Band Analysis</div>
<div class="section-subtitle">RSS decomposition LSF: n10 (M2 correctable) MSF: n=11-50 (support print-through) HSF: n>50</div>
</div>
</div>
<div class="section-content">
<table class="comparison-table">
<thead>
<tr>
<th>Band</th>
<th style="text-align:center">40° vs 20°</th>
<th style="text-align:center">60° vs 20°</th>
<th style="text-align:center">90° MFG</th>
<th>Physics</th>
</tr>
</thead>
<tbody>
<tr>
<td><span style="color:{BAND_COLORS['lsf']}"></span> LSF (n 10)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('band_rss', {}).get('lsf', 0):.2f} nm</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('band_rss', {}).get('lsf', 0):.2f} nm</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('band_rss', {}).get('lsf', 0):.2f} nm</td>
<td style="color:{theme['text_muted']}">M2 hexapod correctable</td>
</tr>
<tr>
<td><span style="color:{BAND_COLORS['msf']}"></span> MSF (n = 11-50)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('band_rss', {}).get('msf', 0):.2f} nm</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('band_rss', {}).get('msf', 0):.2f} nm</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('band_rss', {}).get('msf', 0):.2f} nm</td>
<td style="color:{theme['text_muted']}">Support print-through</td>
</tr>
<tr>
<td><span style="color:{BAND_COLORS['hsf']}"></span> HSF (n > 50)</td>
<td class="value-cell" style="text-align:center">{metrics_40.get('band_rss', {}).get('hsf', 0):.2f} nm</td>
<td class="value-cell" style="text-align:center">{metrics_60.get('band_rss', {}).get('hsf', 0):.2f} nm</td>
<td class="value-cell" style="text-align:center">{metrics_90.get('band_rss', {}).get('hsf', 0):.2f} nm</td>
<td style="color:{theme['text_muted']}">High frequency (mesh limit)</td>
</tr>
<tr style="background:{theme['surface_bg']}">
<td><strong>Total RSS</strong></td>
<td class="value-cell" style="text-align:center"><strong>{metrics_40.get('band_rss', {}).get('total', 0):.2f} nm</strong></td>
<td class="value-cell" style="text-align:center"><strong>{metrics_60.get('band_rss', {}).get('total', 0):.2f} nm</strong></td>
<td class="value-cell" style="text-align:center"><strong>{metrics_90.get('band_rss', {}).get('total', 0):.2f} nm</strong></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Surface Plots -->
<div class="section">
<div class="section-header">
<div>
<div class="section-title">Surface Residual Visualization</div>
<div class="section-subtitle">WFE residual after J1-J4 removal Visual amplification applied for clarity</div>
</div>
</div>
<div class="section-content">
<div class="plots-grid">
<div class="plot-card">
<div class="plot-header angle-40">40° vs 20° (Relative)</div>
<div id="plot-40" class="plot-container"></div>
</div>
<div class="plot-card">
<div class="plot-header angle-60">60° vs 20° (Relative)</div>
<div id="plot-60" class="plot-container"></div>
</div>
<div class="plot-card">
<div class="plot-header angle-90">90° Manufacturing (Absolute)</div>
<div id="plot-90" class="plot-container"></div>
</div>
</div>
</div>
</div>
<!-- Zernike Coefficients Bar Chart -->
<div class="section">
<div class="section-header">
<div>
<div class="section-title">Zernike Coefficient Magnitudes</div>
<div class="section-subtitle">Absolute values Color-coded by spatial frequency band</div>
</div>
</div>
<div class="section-content">
<div id="coeff-chart" class="plot-container"></div>
<div class="band-legend">
<div class="band-item">
<div class="band-dot" style="background:{BAND_COLORS['lsf']}"></div>
<span>LSF (n 10)</span>
</div>
<div class="band-item">
<div class="band-dot" style="background:{BAND_COLORS['msf']}"></div>
<span>MSF (n = 11-50)</span>
</div>
<div class="band-item">
<div class="band-dot" style="background:{BAND_COLORS['hsf']}"></div>
<span>HSF (n > 50)</span>
</div>
</div>
</div>
</div>
</div>
<script>
// Plot data
{self._generate_plot_js(all_data, cfg)}
</script>
</body>
</html>'''
return html
def _compute_weighted_score(self, rms_40: float, rms_60: float, rms_90: float) -> float:
"""Compute weighted optimization score."""
# Standard weights from M1 optimization
w40 = 100 / 4.0 # Target 4 nm
w60 = 50 / 10.0 # Target 10 nm
w90 = 20 / 20.0 # Target 20 nm
return w40 * rms_40 + w60 * rms_60 + w90 * rms_90
def _get_status_class(self, value: float, target: float, warn: float) -> str:
"""Get CSS class for value status."""
if value <= target:
return ' pass'
elif value <= warn:
return ' warn'
else:
return ' fail'
def _generate_plot_js(self, all_data: Dict[str, Dict], cfg: Dict) -> str:
"""Generate JavaScript for Plotly plots."""
_load_dependencies()
amp = cfg.get('amp', 0.5)
downsample = cfg.get('plot_downsample', 8000)
js_parts = []
# Generate 3D surface plots for each angle
for key, angle, div_id in [
('40_vs_20', '40', 'plot-40'),
('60_vs_20', '60', 'plot-60'),
('90_abs', '90', 'plot-90'),
]:
data = all_data.get(key, {})
X = data.get('X', np.array([]))
Y = data.get('Y', np.array([]))
W = data.get('metrics', {}).get('W_res_filt', np.array([]))
if len(X) == 0:
continue
# Downsample
n = len(X)
if n > downsample:
rng = np.random.default_rng(42)
sel = rng.choice(n, size=downsample, replace=False)
Xp, Yp, Wp = X[sel], Y[sel], W[sel]
else:
Xp, Yp, Wp = X, Y, W
res_amp = amp * Wp
max_amp = float(np.max(np.abs(res_amp))) if res_amp.size else 1.0
# Build triangulation
try:
tri = _Triangulation(Xp, Yp)
i_arr = tri.triangles[:, 0].tolist()
j_arr = tri.triangles[:, 1].tolist()
k_arr = tri.triangles[:, 2].tolist()
except:
i_arr, j_arr, k_arr = [], [], []
color = ANGLE_COLORS[angle]
js_parts.append(f'''
// {angle}° Plot
Plotly.newPlot('{div_id}', [{{
type: 'mesh3d',
x: {Xp.tolist()},
y: {Yp.tolist()},
z: {res_amp.tolist()},
i: {i_arr},
j: {j_arr},
k: {k_arr},
intensity: {res_amp.tolist()},
colorscale: 'RdBu_r',
showscale: true,
colorbar: {{
title: 'WFE (nm)',
thickness: 12,
len: 0.6
}},
lighting: {{
ambient: 0.5,
diffuse: 0.8,
specular: 0.3
}},
hovertemplate: 'X: %{{x:.1f}} mm<br>Y: %{{y:.1f}} mm<br>WFE: %{{z:.2f}} nm<extra></extra>'
}}], {{
margin: {{t: 10, b: 10, l: 10, r: 10}},
scene: {{
camera: {{eye: {{x: 1.2, y: 1.2, z: 0.8}}}},
xaxis: {{title: 'X (mm)', showgrid: true, gridcolor: 'rgba(100,100,100,0.2)'}},
yaxis: {{title: 'Y (mm)', showgrid: true, gridcolor: 'rgba(100,100,100,0.2)'}},
zaxis: {{title: 'WFE (nm)', range: [{-max_amp * 3}, {max_amp * 3}], showgrid: true, gridcolor: 'rgba(100,100,100,0.2)'}},
aspectmode: 'manual',
aspectratio: {{x: 1, y: 1, z: 0.4}},
bgcolor: '#f8fafc'
}},
paper_bgcolor: '#ffffff'
}}, {{responsive: true}});
''')
# Generate coefficient bar chart
metrics_40 = all_data.get('40_vs_20', {}).get('metrics', {})
coeffs = metrics_40.get('coefficients', np.array([]))
n_modes = len(coeffs) if len(coeffs) > 0 else 50
if len(coeffs) > 0:
bands = classify_modes_by_band(n_modes, cfg.get('lsf_max', 10), cfg.get('msf_max', 50))
bar_colors = []
labels = []
for j in range(1, n_modes + 1):
n, m = noll_indices(j)
labels.append(f'J{j} {zernike_common_name(n, m)}')
if j in bands['lsf']:
bar_colors.append(BAND_COLORS['lsf'])
elif j in bands['msf']:
bar_colors.append(BAND_COLORS['msf'])
else:
bar_colors.append(BAND_COLORS['hsf'])
coeff_abs = np.abs(coeffs).tolist()
js_parts.append(f'''
// Coefficient Bar Chart
Plotly.newPlot('coeff-chart', [{{
type: 'bar',
x: {coeff_abs},
y: {labels},
orientation: 'h',
marker: {{
color: {bar_colors}
}},
hovertemplate: '%{{y}}<br>|Coeff| = %{{x:.3f}} nm<extra></extra>'
}}], {{
margin: {{t: 20, b: 40, l: 200, r: 20}},
height: 800,
xaxis: {{title: '|Coefficient| (nm)', gridcolor: 'rgba(100,100,100,0.2)'}},
yaxis: {{autorange: 'reversed'}},
paper_bgcolor: '#ffffff',
plot_bgcolor: '#f8fafc'
}}, {{responsive: true}});
''')
return '\n'.join(js_parts)
def _generate(self, config: InsightConfig) -> InsightResult:
"""Generate comprehensive Zernike dashboard."""
self._load_data()
cfg = {**DEFAULT_CONFIG, **config.extra}
n_modes = cfg['n_modes']
filter_orders = cfg['filter_low_orders']
disp_unit = cfg['disp_unit']
use_opd = cfg['use_opd']
# Map subcases
disps = self._displacements
if '1' in disps and '2' in disps:
sc_map = {'90': '1', '20': '2', '40': '3', '60': '4'}
elif '90' in disps and '20' in disps:
sc_map = {'90': '90', '20': '20', '40': '40', '60': '60'}
else:
available = sorted(disps.keys(), key=lambda x: int(x) if x.isdigit() else 0)
if len(available) >= 4:
sc_map = {'90': available[0], '20': available[1],
'40': available[2], '60': available[3]}
else:
return InsightResult(success=False,
error=f"Need 4 subcases, found: {available}")
# Validate
for angle, label in sc_map.items():
if label not in disps:
return InsightResult(success=False,
error=f"Subcase '{label}' (angle {angle}) not found")
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
output_dir = config.output_dir or self.insights_path
output_dir.mkdir(parents=True, exist_ok=True)
all_data = {}
# Load reference data (20°)
data_ref = self._build_wfe_arrays(sc_map['20'], disp_unit, use_opd)
# Process each angle
for angle in ['40', '60']:
data = self._build_wfe_arrays(sc_map[angle], disp_unit, use_opd)
rel_data = self._compute_relative_wfe(data, data_ref)
metrics = self._compute_metrics(
rel_data['X'], rel_data['Y'], rel_data['WFE'],
n_modes, filter_orders, cfg
)
all_data[f'{angle}_vs_20'] = {
'X': rel_data['X'],
'Y': rel_data['Y'],
'WFE': rel_data['WFE'],
'metrics': metrics,
}
# 90° absolute (manufacturing)
data_90 = self._build_wfe_arrays(sc_map['90'], disp_unit, use_opd)
metrics_90 = self._compute_metrics(
data_90['X'], data_90['Y'], data_90['WFE'],
n_modes, filter_orders, cfg
)
all_data['90_abs'] = {
'X': data_90['X'],
'Y': data_90['Y'],
'WFE': data_90['WFE'],
'metrics': metrics_90,
}
# Generate HTML
html = self._generate_html(all_data, cfg, timestamp)
file_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
html_path = output_dir / f"zernike_dashboard_{file_timestamp}.html"
html_path.write_text(html, encoding='utf-8')
# Build summary
summary = {
'40_vs_20_filtered_rms': all_data['40_vs_20']['metrics']['filtered_rms'],
'60_vs_20_filtered_rms': all_data['60_vs_20']['metrics']['filtered_rms'],
'90_mfg_rms_j1to3': all_data['90_abs']['metrics']['rms_j1to3'],
'90_mfg_filtered_rms': all_data['90_abs']['metrics']['filtered_rms'],
'weighted_score': self._compute_weighted_score(
all_data['40_vs_20']['metrics']['filtered_rms'],
all_data['60_vs_20']['metrics']['filtered_rms'],
all_data['90_abs']['metrics']['rms_j1to3']
),
'method': 'OPD' if use_opd else 'Standard',
'n_modes': n_modes,
}
return InsightResult(
success=True,
html_path=html_path,
summary=summary
)