Major improvements to Zernike WFE visualization: - Add ZernikeDashboardInsight: Unified dashboard with all orientations (40°, 60°, 90°) on one page with light theme and executive summary - Add OPD method toggle: Switch between Standard (Z-only) and OPD (X,Y,Z) methods in ZernikeWFEInsight with interactive buttons - Add lateral displacement maps: Visualize X,Y displacement for each orientation - Add displacement component views: Toggle between WFE, ΔX, ΔY, ΔZ in relative views - Add metrics comparison table showing both methods side-by-side New extractors: - extract_zernike_figure.py: ZernikeOPDExtractor using BDF geometry interpolation - extract_zernike_opd.py: Parabola-based OPD with focal length Key finding: OPD method gives 8-11% higher WFE values than Standard method (more conservative/accurate for surfaces with lateral displacement under gravity) Documentation updates: - SYS_12: Added E22 ZernikeOPD as recommended method - SYS_16: Added ZernikeDashboard, updated ZernikeWFE with OPD features - Cheatsheet: Added Zernike method comparison table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1331 lines
52 KiB
Python
1331 lines
52 KiB
Python
"""
|
|
Zernike Wavefront Error (WFE) Insight
|
|
|
|
Provides 3D surface visualization of mirror wavefront errors with
|
|
Zernike polynomial decomposition. Generates three views:
|
|
- 40 deg vs 20 deg (operational tilt comparison)
|
|
- 60 deg vs 20 deg (operational tilt comparison)
|
|
- 90 deg Manufacturing (absolute with optician workload metrics)
|
|
|
|
Supports two WFE computation methods:
|
|
- Standard: Z-displacement only at original (x, y) coordinates
|
|
- OPD: Accounts for lateral (X, Y) displacement via interpolation (RECOMMENDED)
|
|
|
|
Applicable to: Mirror optimization studies with multi-subcase gravity loads.
|
|
"""
|
|
|
|
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 import for OPD extractor
|
|
_ZernikeOPDExtractor = None
|
|
|
|
# Lazy imports to avoid startup overhead
|
|
_plotly_loaded = False
|
|
_go = None
|
|
_make_subplots = None
|
|
_Triangulation = None
|
|
_OP2 = None
|
|
_BDF = 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
|
|
|
|
|
|
# ============================================================================
|
|
# 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 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): "Primary 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",
|
|
(5, -3): "Sec Trefoil X", (5, 3): "Sec Trefoil Y",
|
|
(5, -5): "Pentafoil X", (5, 5): "Pentafoil Y",
|
|
(6, 0): "Sec Spherical",
|
|
}
|
|
return names.get((n, m), f"Z(n={n}, m={m})")
|
|
|
|
|
|
def zernike_label(j: int) -> str:
|
|
"""Get label for Zernike coefficient J{j}."""
|
|
n, m = noll_indices(j)
|
|
return f"J{j:02d} - {zernike_common_name(n, m)} (n={n}, m={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
|
|
|
|
|
|
# ============================================================================
|
|
# Configuration Defaults
|
|
# ============================================================================
|
|
DEFAULT_CONFIG = {
|
|
'n_modes': 36, # Reduced from 50 for faster computation (covers through 7th order)
|
|
'amp': 0.5, # Visual deformation scale
|
|
'pancake': 3.0, # Z-axis range multiplier
|
|
'plot_downsample': 8000, # Reduced from 10000 for faster rendering
|
|
'filter_low_orders': 4, # Piston, tip, tilt, defocus
|
|
'colorscale': 'Turbo',
|
|
'disp_unit': 'mm',
|
|
'show_bar_chart': True,
|
|
}
|
|
|
|
|
|
@register_insight
|
|
class ZernikeWFEInsight(StudyInsight):
|
|
"""
|
|
Zernike Wavefront Error visualization for mirror optimization.
|
|
|
|
Generates interactive 3D surface plots showing:
|
|
- Residual WFE after Zernike fit
|
|
- Coefficient bar charts
|
|
- RMS metrics tables
|
|
- Manufacturing orientation analysis
|
|
"""
|
|
|
|
insight_type = "zernike_wfe"
|
|
name = "Zernike WFE Analysis"
|
|
description = "3D wavefront error surface with Zernike decomposition"
|
|
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
|
|
self._opd_extractor: Optional[Any] = None # Cached OPD extractor
|
|
|
|
def can_generate(self) -> bool:
|
|
"""Check if OP2 and geometry files exist.
|
|
|
|
Uses fast non-recursive search to avoid slow glob operations.
|
|
"""
|
|
# Fast search order: best_design_archive first, then iterations
|
|
search_locations = [
|
|
(self.study_path / "3_results" / "best_design_archive", False), # (path, is_iter_parent)
|
|
(self.study_path / "2_iterations", True), # iterations have subdirs
|
|
(self.setup_path / "model", False),
|
|
]
|
|
|
|
op2_candidates = []
|
|
|
|
for search_path, is_iter_parent in search_locations:
|
|
if not search_path.exists():
|
|
continue
|
|
|
|
if is_iter_parent:
|
|
# For iterations, check each subdir (one level only)
|
|
try:
|
|
for subdir in search_path.iterdir():
|
|
if subdir.is_dir():
|
|
for f in subdir.iterdir():
|
|
if f.suffix.lower() == '.op2':
|
|
op2_candidates.append(f)
|
|
except (PermissionError, OSError):
|
|
continue
|
|
else:
|
|
# Direct check (non-recursive)
|
|
try:
|
|
for f in search_path.iterdir():
|
|
if f.suffix.lower() == '.op2':
|
|
op2_candidates.append(f)
|
|
# Also check one level down for best_design_archive
|
|
elif f.is_dir():
|
|
for sub in f.iterdir():
|
|
if sub.suffix.lower() == '.op2':
|
|
op2_candidates.append(sub)
|
|
except (PermissionError, OSError):
|
|
continue
|
|
|
|
if op2_candidates:
|
|
break # Found some, stop searching
|
|
|
|
if not op2_candidates:
|
|
return False
|
|
|
|
# Pick newest OP2
|
|
self.op2_path = max(op2_candidates, key=lambda p: p.stat().st_mtime)
|
|
|
|
# Find geometry
|
|
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 # Already loaded
|
|
|
|
_load_dependencies()
|
|
|
|
# Read geometry
|
|
bdf = _BDF()
|
|
bdf.read_bdf(str(self.geo_path))
|
|
self._node_geo = {int(nid): node.get_position()
|
|
for nid, node in bdf.nodes.items()}
|
|
|
|
# Read displacements
|
|
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 X, Y, WFE arrays using standard Z-only method.
|
|
|
|
This is the original method that uses only Z-displacement
|
|
at the original (x, y) coordinates.
|
|
"""
|
|
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 # Z-disp to WFE
|
|
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),
|
|
'method': 'standard',
|
|
}
|
|
|
|
def _build_wfe_arrays_opd(
|
|
self,
|
|
label: str,
|
|
disp_unit: str = 'mm'
|
|
) -> Dict[str, np.ndarray]:
|
|
"""Build X, Y, WFE arrays using OPD method (accounts for lateral displacement).
|
|
|
|
This is the RECOMMENDED method that:
|
|
1. Uses deformed (x+dx, y+dy) coordinates for Zernike fitting
|
|
2. Computes true surface error via interpolation of undeformed geometry
|
|
|
|
Uses cached OPD extractor to avoid re-reading OP2/BDF files for each subcase.
|
|
"""
|
|
_load_dependencies()
|
|
|
|
# Reuse cached extractor to avoid re-reading files
|
|
if self._opd_extractor is None:
|
|
self._opd_extractor = _ZernikeOPDExtractor(
|
|
self.op2_path,
|
|
bdf_path=self.geo_path,
|
|
displacement_unit=disp_unit
|
|
)
|
|
|
|
opd_data = self._opd_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'],
|
|
'method': 'opd',
|
|
}
|
|
|
|
def _build_wfe_arrays(
|
|
self,
|
|
label: str,
|
|
disp_unit: str = 'mm',
|
|
method: str = 'opd'
|
|
) -> Dict[str, np.ndarray]:
|
|
"""Build X, Y, WFE arrays for a subcase.
|
|
|
|
Args:
|
|
label: Subcase label
|
|
disp_unit: Displacement unit ('mm' or 'm')
|
|
method: 'opd' (recommended) or 'standard'
|
|
|
|
Returns:
|
|
Dict with X, Y, WFE, node_ids, dx, dy, dz, lateral_disp arrays
|
|
"""
|
|
if method == '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 displacement and WFE (target - reference) for common nodes.
|
|
|
|
Args:
|
|
data1: Target subcase data dict
|
|
data2: Reference subcase data dict
|
|
|
|
Returns:
|
|
Dict with relative WFE arrays AND relative displacement components (dx, dy, dz)
|
|
"""
|
|
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']
|
|
|
|
# Build reference maps for all data
|
|
ref_map = {int(nid): (x, y, w) for nid, x, y, w in zip(nids2, X2, Y2, WFE2)}
|
|
|
|
# Maps for displacement components
|
|
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 = [], [], []
|
|
lateral_rel = []
|
|
|
|
for nid, x, y, w in zip(nids1, X1, Y1, WFE1):
|
|
nid = int(nid)
|
|
if nid not in ref_map:
|
|
continue
|
|
if 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)
|
|
|
|
# Compute relative displacements (target - reference)
|
|
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])
|
|
|
|
# Relative lateral displacement magnitude
|
|
lat1 = np.sqrt(dx1_map[nid]**2 + dy1_map[nid]**2)
|
|
lat2 = np.sqrt(dx2_map[nid]**2 + dy2_map[nid]**2)
|
|
lateral_rel.append(lat1 - lat2)
|
|
|
|
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), # Relative X displacement (mm)
|
|
'dy': np.array(dy_rel), # Relative Y displacement (mm)
|
|
'dz': np.array(dz_rel), # Relative Z displacement (mm)
|
|
'lateral_disp': np.array(lateral_rel) if lateral_rel else np.zeros(len(X_rel)),
|
|
'method': data1.get('method', 'unknown'),
|
|
}
|
|
|
|
def _compute_metrics(
|
|
self,
|
|
X: np.ndarray,
|
|
Y: np.ndarray,
|
|
W_nm: np.ndarray,
|
|
n_modes: int,
|
|
filter_orders: int
|
|
) -> Dict[str, Any]:
|
|
"""Compute RMS metrics and Zernike coefficients."""
|
|
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])
|
|
|
|
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_filter_j1to3': float(np.sqrt(np.mean(W_res_filt_j1to3**2))),
|
|
'W_res_filt': W_res_filt,
|
|
}
|
|
|
|
def _compute_aberration_magnitudes(self, coeffs: np.ndarray) -> Dict[str, float]:
|
|
"""Compute magnitude of specific aberration modes."""
|
|
return {
|
|
'defocus_nm': float(abs(coeffs[3])) if len(coeffs) > 3 else 0.0,
|
|
'astigmatism_rms': float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)) if len(coeffs) > 5 else 0.0,
|
|
'coma_rms': float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)) if len(coeffs) > 7 else 0.0,
|
|
'trefoil_rms': float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) if len(coeffs) > 9 else 0.0,
|
|
'spherical_nm': float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0,
|
|
}
|
|
|
|
def _generate_view_html(
|
|
self,
|
|
title: str,
|
|
X: np.ndarray,
|
|
Y: np.ndarray,
|
|
W_nm: np.ndarray,
|
|
rms_data: Dict,
|
|
config: Dict,
|
|
is_relative: bool = False,
|
|
ref_title: str = "20 deg",
|
|
abs_pair: Optional[Tuple[float, float]] = None,
|
|
is_manufacturing: bool = False,
|
|
mfg_metrics: Optional[Dict] = None,
|
|
correction_metrics: Optional[Dict] = None,
|
|
) -> str:
|
|
"""Generate HTML for a single view."""
|
|
_load_dependencies()
|
|
|
|
n_modes = config.get('n_modes', 50)
|
|
amp = config.get('amp', 0.5)
|
|
pancake = config.get('pancake', 3.0)
|
|
downsample = config.get('plot_downsample', 10000)
|
|
colorscale = config.get('colorscale', 'Turbo')
|
|
show_bar = config.get('show_bar_chart', True)
|
|
|
|
coeffs = rms_data['coefficients']
|
|
global_rms = rms_data['global_rms']
|
|
filtered_rms = rms_data['filtered_rms']
|
|
W_res_filt = rms_data['W_res_filt']
|
|
|
|
labels = [zernike_label(j) for j in range(1, n_modes + 1)]
|
|
coeff_abs = np.abs(coeffs)
|
|
|
|
# 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_res_filt[sel]
|
|
else:
|
|
Xp, Yp, Wp = X, Y, W_res_filt
|
|
|
|
res_amp = amp * Wp
|
|
max_amp = float(np.max(np.abs(res_amp))) if res_amp.size else 1.0
|
|
|
|
# Build mesh
|
|
mesh_traces = []
|
|
try:
|
|
tri = _Triangulation(Xp, Yp)
|
|
if tri.triangles is not None and len(tri.triangles) > 0:
|
|
i, j, k = tri.triangles.T
|
|
mesh_traces.append(_go.Mesh3d(
|
|
x=Xp, y=Yp, z=res_amp,
|
|
i=i, j=j, k=k,
|
|
intensity=res_amp,
|
|
colorscale=colorscale,
|
|
opacity=1.0,
|
|
flatshading=False,
|
|
lighting=dict(ambient=0.4, diffuse=0.8, specular=0.3,
|
|
roughness=0.5, fresnel=0.2),
|
|
lightposition=dict(x=100, y=200, z=300),
|
|
showscale=True,
|
|
colorbar=dict(title=dict(text="Residual (nm)", side="right"),
|
|
thickness=15, len=0.6, tickformat=".1f"),
|
|
hovertemplate="X: %{x:.1f}<br>Y: %{y:.1f}<br>Residual: %{z:.2f} nm<extra></extra>"
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
if not mesh_traces:
|
|
mesh_traces.append(_go.Scatter3d(
|
|
x=Xp, y=Yp, z=res_amp,
|
|
mode='markers',
|
|
marker=dict(size=2, color=res_amp, colorscale=colorscale, showscale=True),
|
|
showlegend=False
|
|
))
|
|
|
|
title_suffix = f" (relative to {ref_title})" if is_relative else " (absolute)"
|
|
|
|
# Build subplots
|
|
if is_manufacturing and mfg_metrics and correction_metrics:
|
|
fig = _make_subplots(
|
|
rows=5, cols=1,
|
|
specs=[[{"type": "scene"}], [{"type": "table"}], [{"type": "table"}],
|
|
[{"type": "table"}], [{"type": "xy"}]],
|
|
row_heights=[0.38, 0.12, 0.12, 0.18, 0.20],
|
|
vertical_spacing=0.025,
|
|
subplot_titles=[
|
|
f"<b>Surface Residual - {title}{title_suffix}</b>",
|
|
"<b>RMS Metrics (Absolute 90 deg)</b>",
|
|
"<b>Mode Magnitudes at 90 deg</b>",
|
|
"<b>Pre-Correction (90 deg - 20 deg)</b>",
|
|
"<b>|Zernike Coefficients| (nm)</b>"
|
|
]
|
|
)
|
|
elif show_bar:
|
|
fig = _make_subplots(
|
|
rows=4, cols=1,
|
|
specs=[[{"type": "scene"}], [{"type": "table"}],
|
|
[{"type": "table"}], [{"type": "xy"}]],
|
|
row_heights=[0.45, 0.12, 0.25, 0.18],
|
|
vertical_spacing=0.03,
|
|
subplot_titles=[
|
|
f"<b>Surface Residual - {title}{title_suffix}</b>",
|
|
"<b>RMS Metrics</b>",
|
|
f"<b>Zernike Coefficients ({n_modes} modes)</b>",
|
|
"<b>|Zernike Coefficients| (nm)</b>"
|
|
]
|
|
)
|
|
else:
|
|
fig = _make_subplots(
|
|
rows=3, cols=1,
|
|
specs=[[{"type": "scene"}], [{"type": "table"}], [{"type": "table"}]],
|
|
row_heights=[0.55, 0.15, 0.30],
|
|
vertical_spacing=0.03,
|
|
subplot_titles=[
|
|
f"<b>Surface Residual - {title}{title_suffix}</b>",
|
|
"<b>RMS Metrics</b>",
|
|
f"<b>Zernike Coefficients ({n_modes} modes)</b>"
|
|
]
|
|
)
|
|
|
|
# Add mesh
|
|
for tr in mesh_traces:
|
|
fig.add_trace(tr, row=1, col=1)
|
|
|
|
# Configure 3D scene
|
|
fig.update_scenes(
|
|
camera=dict(eye=dict(x=1.2, y=1.2, z=0.8), up=dict(x=0, y=0, z=1)),
|
|
xaxis=dict(title="X (mm)", showgrid=True,
|
|
gridcolor='rgba(128,128,128,0.3)',
|
|
showbackground=True, backgroundcolor='rgba(240,240,240,0.9)'),
|
|
yaxis=dict(title="Y (mm)", showgrid=True,
|
|
gridcolor='rgba(128,128,128,0.3)',
|
|
showbackground=True, backgroundcolor='rgba(240,240,240,0.9)'),
|
|
zaxis=dict(title="Residual (nm)",
|
|
range=[-max_amp * pancake, max_amp * pancake],
|
|
showgrid=True, gridcolor='rgba(128,128,128,0.3)',
|
|
showbackground=True, backgroundcolor='rgba(230,230,250,0.9)'),
|
|
aspectmode='manual',
|
|
aspectratio=dict(x=1, y=1, z=0.4)
|
|
)
|
|
|
|
# Add tables
|
|
if is_relative and abs_pair:
|
|
abs_global, abs_filtered = abs_pair
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Metric</b>", "<b>Relative (nm)</b>", "<b>Absolute (nm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
["Global RMS", "Filtered RMS (J1-J4 removed)"],
|
|
[f"{global_rms:.2f}", f"{filtered_rms:.2f}"],
|
|
[f"{abs_global:.2f}", f"{abs_filtered:.2f}"],
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=2, col=1)
|
|
elif is_manufacturing and mfg_metrics and correction_metrics:
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Metric</b>", "<b>Value (nm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
["Global RMS", "Filtered RMS (J1-J4)"],
|
|
[f"{global_rms:.2f}", f"{filtered_rms:.2f}"]
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=2, col=1)
|
|
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Mode</b>", "<b>Value (nm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
["Filtered RMS (J1-J3, with defocus)", "Astigmatism (J5+J6)",
|
|
"Coma (J7+J8)", "Trefoil (J9+J10)", "Spherical (J11)"],
|
|
[f"{rms_data['rms_filter_j1to3']:.2f}",
|
|
f"{mfg_metrics['astigmatism_rms']:.2f}",
|
|
f"{mfg_metrics['coma_rms']:.2f}",
|
|
f"{mfg_metrics['trefoil_rms']:.2f}",
|
|
f"{mfg_metrics['spherical_nm']:.2f}"]
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=3, col=1)
|
|
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Mode</b>", "<b>Correction (nm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
["Total RMS (J1-J3 filter)", "Defocus (J4)",
|
|
"Astigmatism (J5+J6)", "Coma (J7+J8)"],
|
|
[f"{correction_metrics['rms_filter_j1to3']:.2f}",
|
|
f"{correction_metrics['defocus_nm']:.2f}",
|
|
f"{correction_metrics['astigmatism_rms']:.2f}",
|
|
f"{correction_metrics['coma_rms']:.2f}"]
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=4, col=1)
|
|
else:
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Metric</b>", "<b>Value (nm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
["Global RMS", "Filtered RMS (J1-J4 removed)"],
|
|
[f"{global_rms:.2f}", f"{filtered_rms:.2f}"]
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=2, col=1)
|
|
|
|
# Coefficients table
|
|
if not (is_manufacturing and mfg_metrics and correction_metrics):
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Noll j</b>", "<b>Label</b>", "<b>|Coeff| (nm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
list(range(1, n_modes + 1)),
|
|
labels,
|
|
[f"{c:.3f}" for c in coeff_abs]
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=3, col=1)
|
|
|
|
# Bar chart
|
|
if show_bar:
|
|
bar_row = 5 if (is_manufacturing and mfg_metrics and correction_metrics) else 4
|
|
fig.add_trace(
|
|
_go.Bar(
|
|
x=coeff_abs.tolist(), y=labels,
|
|
orientation='h', marker_color='#6366f1',
|
|
hovertemplate="%{y}<br>|Coeff| = %{x:.3f} nm<extra></extra>",
|
|
showlegend=False
|
|
),
|
|
row=bar_row, col=1
|
|
)
|
|
|
|
# Layout
|
|
height = 1500 if (is_manufacturing and mfg_metrics and correction_metrics) else 1300
|
|
fig.update_layout(
|
|
width=1400, height=height,
|
|
margin=dict(t=60, b=20, l=20, r=20),
|
|
paper_bgcolor='#111827', plot_bgcolor='#1f2937',
|
|
font=dict(color='white'),
|
|
title=dict(text=f"<b>Atomizer Zernike Analysis - {title}</b>",
|
|
x=0.5, font=dict(size=18))
|
|
)
|
|
|
|
return fig.to_html(include_plotlyjs='cdn', full_html=True)
|
|
|
|
def _generate_lateral_map_html(
|
|
self,
|
|
title: str,
|
|
data: Dict[str, np.ndarray],
|
|
config: Dict,
|
|
) -> str:
|
|
"""Generate HTML for lateral displacement visualization.
|
|
|
|
Shows a 3D surface colored by lateral displacement magnitude,
|
|
with metrics table showing max/RMS/mean lateral displacement.
|
|
"""
|
|
_load_dependencies()
|
|
|
|
X = data['X']
|
|
Y = data['Y']
|
|
lateral_disp = data['lateral_disp'] # in mm
|
|
downsample = config.get('plot_downsample', 10000)
|
|
|
|
# Convert to µm for display
|
|
lateral_um = lateral_disp * 1000.0 # mm to µm
|
|
|
|
# Downsample
|
|
n = len(X)
|
|
if n > downsample:
|
|
rng = np.random.default_rng(42)
|
|
sel = rng.choice(n, size=downsample, replace=False)
|
|
Xp, Yp, Lp = X[sel], Y[sel], lateral_um[sel]
|
|
else:
|
|
Xp, Yp, Lp = X, Y, lateral_um
|
|
|
|
# Build mesh
|
|
mesh_traces = []
|
|
try:
|
|
tri = _Triangulation(Xp, Yp)
|
|
if tri.triangles is not None and len(tri.triangles) > 0:
|
|
i, j, k = tri.triangles.T
|
|
mesh_traces.append(_go.Mesh3d(
|
|
x=Xp, y=Yp, z=Lp,
|
|
i=i, j=j, k=k,
|
|
intensity=Lp,
|
|
colorscale='Viridis',
|
|
opacity=1.0,
|
|
flatshading=False,
|
|
lighting=dict(ambient=0.4, diffuse=0.8, specular=0.3),
|
|
lightposition=dict(x=100, y=200, z=300),
|
|
showscale=True,
|
|
colorbar=dict(title=dict(text="Lateral (µm)", side="right"),
|
|
thickness=15, len=0.6, tickformat=".3f"),
|
|
hovertemplate="X: %{x:.1f}<br>Y: %{y:.1f}<br>Lateral: %{z:.4f} µm<extra></extra>"
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
if not mesh_traces:
|
|
mesh_traces.append(_go.Scatter3d(
|
|
x=Xp, y=Yp, z=Lp,
|
|
mode='markers',
|
|
marker=dict(size=2, color=Lp, colorscale='Viridis', showscale=True),
|
|
showlegend=False
|
|
))
|
|
|
|
# Create figure with subplots
|
|
fig = _make_subplots(
|
|
rows=2, cols=1,
|
|
specs=[[{"type": "scene"}], [{"type": "table"}]],
|
|
row_heights=[0.75, 0.25],
|
|
vertical_spacing=0.03,
|
|
subplot_titles=[
|
|
f"<b>Lateral Displacement Map - {title}</b>",
|
|
"<b>Lateral Displacement Statistics</b>"
|
|
]
|
|
)
|
|
|
|
for tr in mesh_traces:
|
|
fig.add_trace(tr, row=1, col=1)
|
|
|
|
# Stats
|
|
max_lat = float(np.max(lateral_um))
|
|
rms_lat = float(np.sqrt(np.mean(lateral_um**2)))
|
|
mean_lat = float(np.mean(lateral_um))
|
|
min_lat = float(np.min(lateral_um))
|
|
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=["<b>Statistic</b>", "<b>Value (µm)</b>"],
|
|
align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[
|
|
["Max Lateral", "RMS Lateral", "Mean Lateral", "Min Lateral"],
|
|
[f"{max_lat:.4f}", f"{rms_lat:.4f}", f"{mean_lat:.4f}", f"{min_lat:.4f}"]
|
|
], align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=2, col=1)
|
|
|
|
# Configure 3D scene
|
|
max_z = float(np.max(Lp)) if Lp.size else 1.0
|
|
fig.update_scenes(
|
|
camera=dict(eye=dict(x=1.2, y=1.2, z=0.8), up=dict(x=0, y=0, z=1)),
|
|
xaxis=dict(title="X (mm)", showgrid=True, gridcolor='rgba(128,128,128,0.3)'),
|
|
yaxis=dict(title="Y (mm)", showgrid=True, gridcolor='rgba(128,128,128,0.3)'),
|
|
zaxis=dict(title="Lateral (µm)",
|
|
range=[0, max_z * 1.2],
|
|
showgrid=True, gridcolor='rgba(128,128,128,0.3)'),
|
|
aspectmode='manual',
|
|
aspectratio=dict(x=1, y=1, z=0.4)
|
|
)
|
|
|
|
fig.update_layout(
|
|
width=1200, height=900,
|
|
margin=dict(t=60, b=20, l=20, r=20),
|
|
paper_bgcolor='#111827', plot_bgcolor='#1f2937',
|
|
font=dict(color='white'),
|
|
title=dict(text=f"<b>Lateral Displacement Analysis - {title}</b>",
|
|
x=0.5, font=dict(size=18))
|
|
)
|
|
|
|
return fig.to_html(include_plotlyjs='cdn', full_html=True)
|
|
|
|
def _generate_dual_method_view_html(
|
|
self,
|
|
title: str,
|
|
data_std: Dict[str, np.ndarray],
|
|
data_opd: Dict[str, np.ndarray],
|
|
rms_std: Dict,
|
|
rms_opd: Dict,
|
|
config: Dict,
|
|
is_relative: bool = False,
|
|
ref_title: str = "20 deg",
|
|
) -> str:
|
|
"""Generate HTML with toggle between Standard/OPD methods AND X/Y/Z displacement components.
|
|
|
|
For relative views, provides toggles to see:
|
|
- WFE (Z): The main wavefront error view (default)
|
|
- ΔX: Relative X displacement between subcases
|
|
- ΔY: Relative Y displacement between subcases
|
|
- ΔZ: Relative Z displacement between subcases
|
|
"""
|
|
_load_dependencies()
|
|
|
|
n_modes = config.get('n_modes', 50)
|
|
amp = config.get('amp', 0.5)
|
|
pancake = config.get('pancake', 3.0)
|
|
downsample = config.get('plot_downsample', 10000)
|
|
colorscale = config.get('colorscale', 'Turbo')
|
|
|
|
title_suffix = f" (relative to {ref_title})" if is_relative else " (absolute)"
|
|
|
|
# Build traces for both methods (WFE view)
|
|
traces_std_wfe = []
|
|
traces_opd_wfe = []
|
|
|
|
# Build displacement component traces (OPD method only, for relative views)
|
|
traces_dx = []
|
|
traces_dy = []
|
|
traces_dz = []
|
|
|
|
# Helper to build mesh trace
|
|
def build_mesh_trace(Xp, Yp, Zp, colorscale, label, unit, colorbar_title):
|
|
try:
|
|
tri = _Triangulation(Xp, Yp)
|
|
if tri.triangles is not None and len(tri.triangles) > 0:
|
|
i, j, k = tri.triangles.T
|
|
return _go.Mesh3d(
|
|
x=Xp, y=Yp, z=Zp,
|
|
i=i, j=j, k=k,
|
|
intensity=Zp,
|
|
colorscale=colorscale,
|
|
opacity=1.0,
|
|
flatshading=False,
|
|
lighting=dict(ambient=0.4, diffuse=0.8, specular=0.3),
|
|
lightposition=dict(x=100, y=200, z=300),
|
|
showscale=True,
|
|
colorbar=dict(title=dict(text=colorbar_title, side="right"),
|
|
thickness=15, len=0.6, tickformat=".2f" if 'µm' in unit else ".1f"),
|
|
hovertemplate=f"{label}<br>X: %{{x:.1f}}<br>Y: %{{y:.1f}}<br>Value: %{{z:.3f}} {unit}<extra></extra>"
|
|
)
|
|
except Exception:
|
|
pass
|
|
return _go.Scatter3d(
|
|
x=Xp, y=Yp, z=Zp,
|
|
mode='markers',
|
|
marker=dict(size=2, color=Zp, colorscale=colorscale, showscale=True),
|
|
showlegend=False
|
|
)
|
|
|
|
# Build WFE traces for both methods
|
|
for data, rms_data, traces, method_name in [
|
|
(data_std, rms_std, traces_std_wfe, 'Standard'),
|
|
(data_opd, rms_opd, traces_opd_wfe, 'OPD')
|
|
]:
|
|
X, Y = data['X'], data['Y']
|
|
W_res_filt = rms_data['W_res_filt']
|
|
|
|
# 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_res_filt[sel]
|
|
else:
|
|
Xp, Yp, Wp = X, Y, W_res_filt
|
|
|
|
res_amp = amp * Wp
|
|
traces.append(build_mesh_trace(Xp, Yp, res_amp, colorscale, method_name, 'nm', f'{method_name} WFE (nm)'))
|
|
|
|
# Build displacement component traces (for relative views)
|
|
has_displacement_data = is_relative and 'dx' in data_opd and len(data_opd.get('dx', [])) > 0
|
|
if has_displacement_data:
|
|
X, Y = data_opd['X'], data_opd['Y']
|
|
dx_mm = data_opd['dx'] # mm
|
|
dy_mm = data_opd['dy'] # mm
|
|
dz_mm = data_opd['dz'] # mm
|
|
|
|
# Convert to µm for display
|
|
dx_um = dx_mm * 1000.0
|
|
dy_um = dy_mm * 1000.0
|
|
dz_um = dz_mm * 1000.0
|
|
|
|
n = len(X)
|
|
if n > downsample:
|
|
rng = np.random.default_rng(42)
|
|
sel = rng.choice(n, size=downsample, replace=False)
|
|
Xp, Yp = X[sel], Y[sel]
|
|
dxp, dyp, dzp = dx_um[sel], dy_um[sel], dz_um[sel]
|
|
else:
|
|
Xp, Yp = X, Y
|
|
dxp, dyp, dzp = dx_um, dy_um, dz_um
|
|
|
|
# Apply visual amplification for displacement views
|
|
disp_amp = amp * 1000.0 # Scale factor for µm display
|
|
|
|
traces_dx.append(build_mesh_trace(Xp, Yp, dxp * amp, 'RdBu_r', 'ΔX Displacement', 'µm', 'ΔX (µm)'))
|
|
traces_dy.append(build_mesh_trace(Xp, Yp, dyp * amp, 'RdBu_r', 'ΔY Displacement', 'µm', 'ΔY (µm)'))
|
|
traces_dz.append(build_mesh_trace(Xp, Yp, dzp * amp, 'RdBu_r', 'ΔZ Displacement', 'µm', 'ΔZ (µm)'))
|
|
|
|
# Create figure
|
|
fig = _make_subplots(
|
|
rows=2, cols=1,
|
|
specs=[[{"type": "scene"}], [{"type": "table"}]],
|
|
row_heights=[0.65, 0.35],
|
|
vertical_spacing=0.05,
|
|
subplot_titles=[
|
|
f"<b>Surface Analysis - {title}{title_suffix}</b>",
|
|
"<b>Metrics Comparison</b>"
|
|
]
|
|
)
|
|
|
|
# Add all traces in order: [std_wfe, opd_wfe, dx, dy, dz, table]
|
|
# Start with OPD WFE visible (default view)
|
|
for tr in traces_std_wfe:
|
|
tr.visible = False
|
|
fig.add_trace(tr, row=1, col=1)
|
|
for tr in traces_opd_wfe:
|
|
tr.visible = True # Default view
|
|
fig.add_trace(tr, row=1, col=1)
|
|
for tr in traces_dx:
|
|
tr.visible = False
|
|
fig.add_trace(tr, row=1, col=1)
|
|
for tr in traces_dy:
|
|
tr.visible = False
|
|
fig.add_trace(tr, row=1, col=1)
|
|
for tr in traces_dz:
|
|
tr.visible = False
|
|
fig.add_trace(tr, row=1, col=1)
|
|
|
|
# Compute lateral stats (from OPD data)
|
|
lateral_um = data_opd.get('lateral_disp', np.zeros(1)) * 1000.0
|
|
max_lat = float(np.max(np.abs(lateral_um)))
|
|
rms_lat = float(np.sqrt(np.mean(lateral_um**2)))
|
|
|
|
# Compute % difference
|
|
std_filt = rms_std['filtered_rms']
|
|
opd_filt = rms_opd['filtered_rms']
|
|
pct_diff = 100.0 * (opd_filt - std_filt) / std_filt if std_filt > 0 else 0.0
|
|
|
|
# Displacement stats (for relative views)
|
|
disp_stats_rows = []
|
|
if has_displacement_data:
|
|
dx_um = data_opd['dx'] * 1000.0
|
|
dy_um = data_opd['dy'] * 1000.0
|
|
dz_um = data_opd['dz'] * 1000.0
|
|
disp_stats_rows = [
|
|
"ΔX RMS (µm)", "ΔY RMS (µm)", "ΔZ RMS (µm)"
|
|
]
|
|
disp_stats_values_std = ["—", "—", "—"]
|
|
disp_stats_values_opd = [
|
|
f"{float(np.sqrt(np.mean(dx_um**2))):.4f}",
|
|
f"{float(np.sqrt(np.mean(dy_um**2))):.4f}",
|
|
f"{float(np.sqrt(np.mean(dz_um**2))):.4f}"
|
|
]
|
|
disp_stats_diff = ["—", "—", "—"]
|
|
|
|
# Comparison table
|
|
table_headers = ["<b>Metric</b>", "<b>Standard (Z-only)</b>", "<b>OPD (X,Y,Z)</b>", "<b>Difference</b>"]
|
|
table_rows = ["Global RMS (nm)", "Filtered RMS (nm)", "Method", "Max Lateral (µm)", "RMS Lateral (µm)"]
|
|
table_std = [f"{rms_std['global_rms']:.2f}", f"{std_filt:.2f}", "Z-displacement only", "—", "—"]
|
|
table_opd = [f"{rms_opd['global_rms']:.2f}", f"{opd_filt:.2f}", "Deformed coords + OPD", f"{max_lat:.3f}", f"{rms_lat:.3f}"]
|
|
table_diff = ["—", f"{pct_diff:+.2f}%", "← RECOMMENDED", "—", "—"]
|
|
|
|
if has_displacement_data:
|
|
table_rows.extend(disp_stats_rows)
|
|
table_std.extend(disp_stats_values_std)
|
|
table_opd.extend(disp_stats_values_opd)
|
|
table_diff.extend(disp_stats_diff)
|
|
|
|
fig.add_trace(_go.Table(
|
|
header=dict(values=table_headers, align="left", fill_color='#1f2937', font=dict(color='white')),
|
|
cells=dict(values=[table_rows, table_std, table_opd, table_diff],
|
|
align="left", fill_color='#374151', font=dict(color='white'))
|
|
), row=2, col=1)
|
|
|
|
# Build visibility arrays for toggles
|
|
n_std_wfe = len(traces_std_wfe)
|
|
n_opd_wfe = len(traces_opd_wfe)
|
|
n_dx = len(traces_dx)
|
|
n_dy = len(traces_dy)
|
|
n_dz = len(traces_dz)
|
|
# Total traces before table: n_std_wfe + n_opd_wfe + n_dx + n_dy + n_dz
|
|
# Then table is last
|
|
|
|
def make_visibility(show_std_wfe=False, show_opd_wfe=False, show_dx=False, show_dy=False, show_dz=False):
|
|
vis = []
|
|
vis.extend([show_std_wfe] * n_std_wfe)
|
|
vis.extend([show_opd_wfe] * n_opd_wfe)
|
|
vis.extend([show_dx] * n_dx)
|
|
vis.extend([show_dy] * n_dy)
|
|
vis.extend([show_dz] * n_dz)
|
|
vis.append(True) # Table always visible
|
|
return vis
|
|
|
|
# Build button definitions
|
|
buttons_method = [
|
|
dict(label="OPD Method (Recommended)",
|
|
method="update",
|
|
args=[{"visible": make_visibility(show_opd_wfe=True)}]),
|
|
dict(label="Standard Method (Z-only)",
|
|
method="update",
|
|
args=[{"visible": make_visibility(show_std_wfe=True)}]),
|
|
]
|
|
|
|
buttons_component = [
|
|
dict(label="WFE (Z)",
|
|
method="update",
|
|
args=[{"visible": make_visibility(show_opd_wfe=True)}]),
|
|
]
|
|
|
|
if has_displacement_data:
|
|
buttons_component.extend([
|
|
dict(label="ΔX Disp",
|
|
method="update",
|
|
args=[{"visible": make_visibility(show_dx=True)}]),
|
|
dict(label="ΔY Disp",
|
|
method="update",
|
|
args=[{"visible": make_visibility(show_dy=True)}]),
|
|
dict(label="ΔZ Disp",
|
|
method="update",
|
|
args=[{"visible": make_visibility(show_dz=True)}]),
|
|
])
|
|
|
|
# Create update menus
|
|
updatemenus = [
|
|
dict(
|
|
type="buttons",
|
|
direction="right",
|
|
x=0.0, y=1.15,
|
|
xanchor="left",
|
|
showactive=True,
|
|
buttons=buttons_method,
|
|
font=dict(size=11),
|
|
pad=dict(r=10, t=10),
|
|
),
|
|
]
|
|
|
|
if has_displacement_data:
|
|
updatemenus.append(
|
|
dict(
|
|
type="buttons",
|
|
direction="right",
|
|
x=0.55, y=1.15,
|
|
xanchor="left",
|
|
showactive=True,
|
|
buttons=buttons_component,
|
|
font=dict(size=11),
|
|
pad=dict(r=10, t=10),
|
|
)
|
|
)
|
|
|
|
fig.update_layout(updatemenus=updatemenus)
|
|
|
|
# Configure 3D scene
|
|
max_amp_opd = float(np.max(np.abs(amp * rms_opd['W_res_filt']))) if rms_opd['W_res_filt'].size else 1.0
|
|
fig.update_scenes(
|
|
camera=dict(eye=dict(x=1.2, y=1.2, z=0.8), up=dict(x=0, y=0, z=1)),
|
|
xaxis=dict(title="X (mm)", showgrid=True, gridcolor='rgba(128,128,128,0.3)'),
|
|
yaxis=dict(title="Y (mm)", showgrid=True, gridcolor='rgba(128,128,128,0.3)'),
|
|
zaxis=dict(title="Value",
|
|
range=[-max_amp_opd * pancake, max_amp_opd * pancake],
|
|
showgrid=True, gridcolor='rgba(128,128,128,0.3)'),
|
|
aspectmode='manual',
|
|
aspectratio=dict(x=1, y=1, z=0.4)
|
|
)
|
|
|
|
fig.update_layout(
|
|
width=1400, height=1100,
|
|
margin=dict(t=100, b=20, l=20, r=20),
|
|
paper_bgcolor='#111827', plot_bgcolor='#1f2937',
|
|
font=dict(color='white'),
|
|
title=dict(text=f"<b>Atomizer Zernike Analysis - {title}</b>",
|
|
x=0.5, font=dict(size=18)),
|
|
annotations=[
|
|
dict(text="<b>Method:</b>", x=0.0, y=1.18, xref="paper", yref="paper",
|
|
showarrow=False, font=dict(size=12, color='white'), xanchor='left'),
|
|
dict(text="<b>View:</b>", x=0.55, y=1.18, xref="paper", yref="paper",
|
|
showarrow=False, font=dict(size=12, color='white'), xanchor='left') if has_displacement_data else {},
|
|
]
|
|
)
|
|
|
|
return fig.to_html(include_plotlyjs='cdn', full_html=True)
|
|
|
|
def _generate(self, config: InsightConfig) -> InsightResult:
|
|
"""Generate all Zernike WFE views with Standard/OPD toggle and lateral maps.
|
|
|
|
Performance optimizations:
|
|
- Uses cached OPD extractor (reads OP2/BDF only once)
|
|
- Loads all subcase data upfront to minimize I/O
|
|
- Standard method reuses geometry from already-loaded data
|
|
"""
|
|
self._load_data()
|
|
|
|
# Merge config
|
|
cfg = {**DEFAULT_CONFIG, **config.extra}
|
|
cfg['colorscale'] = config.extra.get('colorscale', cfg['colorscale'])
|
|
cfg['amp'] = config.amplification if config.amplification != 1.0 else cfg['amp']
|
|
|
|
n_modes = cfg['n_modes']
|
|
filter_orders = cfg['filter_low_orders']
|
|
disp_unit = cfg['disp_unit']
|
|
|
|
# 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}")
|
|
|
|
# Check subcases
|
|
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)
|
|
|
|
html_files = []
|
|
summary = {}
|
|
|
|
# Load data for BOTH methods - OPD method shares cached extractor
|
|
# Standard method uses already-loaded _node_geo and _displacements
|
|
# Reference: 20 deg
|
|
data_ref_std = self._build_wfe_arrays_standard(sc_map['20'], disp_unit)
|
|
data_ref_opd = self._build_wfe_arrays_opd(sc_map['20'], disp_unit)
|
|
|
|
# 40 deg
|
|
data_40_std = self._build_wfe_arrays_standard(sc_map['40'], disp_unit)
|
|
data_40_opd = self._build_wfe_arrays_opd(sc_map['40'], disp_unit)
|
|
|
|
# 60 deg
|
|
data_60_std = self._build_wfe_arrays_standard(sc_map['60'], disp_unit)
|
|
data_60_opd = self._build_wfe_arrays_opd(sc_map['60'], disp_unit)
|
|
|
|
# 90 deg
|
|
data_90_std = self._build_wfe_arrays_standard(sc_map['90'], disp_unit)
|
|
data_90_opd = self._build_wfe_arrays_opd(sc_map['90'], disp_unit)
|
|
|
|
# =========================================
|
|
# 40 deg vs 20 deg (with dual method toggle)
|
|
# =========================================
|
|
rel_40_std = self._compute_relative_wfe(data_40_std, data_ref_std)
|
|
rel_40_opd = self._compute_relative_wfe(data_40_opd, data_ref_opd)
|
|
|
|
rms_40_std = self._compute_metrics(rel_40_std['X'], rel_40_std['Y'], rel_40_std['WFE'],
|
|
n_modes, filter_orders)
|
|
rms_40_opd = self._compute_metrics(rel_40_opd['X'], rel_40_opd['Y'], rel_40_opd['WFE'],
|
|
n_modes, filter_orders)
|
|
|
|
html_40 = self._generate_dual_method_view_html(
|
|
"40 deg", rel_40_std, rel_40_opd, rms_40_std, rms_40_opd, cfg,
|
|
is_relative=True, ref_title="20 deg")
|
|
path_40 = output_dir / f"zernike_{timestamp}_40_vs_20.html"
|
|
path_40.write_text(html_40, encoding='utf-8')
|
|
html_files.append(path_40)
|
|
|
|
# Lateral map for 40 deg
|
|
html_40_lat = self._generate_lateral_map_html("40 deg", data_40_opd, cfg)
|
|
path_40_lat = output_dir / f"zernike_{timestamp}_40_lateral.html"
|
|
path_40_lat.write_text(html_40_lat, encoding='utf-8')
|
|
html_files.append(path_40_lat)
|
|
|
|
summary['40_vs_20_filtered_rms_std'] = rms_40_std['filtered_rms']
|
|
summary['40_vs_20_filtered_rms_opd'] = rms_40_opd['filtered_rms']
|
|
|
|
# =========================================
|
|
# 60 deg vs 20 deg (with dual method toggle)
|
|
# =========================================
|
|
rel_60_std = self._compute_relative_wfe(data_60_std, data_ref_std)
|
|
rel_60_opd = self._compute_relative_wfe(data_60_opd, data_ref_opd)
|
|
|
|
rms_60_std = self._compute_metrics(rel_60_std['X'], rel_60_std['Y'], rel_60_std['WFE'],
|
|
n_modes, filter_orders)
|
|
rms_60_opd = self._compute_metrics(rel_60_opd['X'], rel_60_opd['Y'], rel_60_opd['WFE'],
|
|
n_modes, filter_orders)
|
|
|
|
html_60 = self._generate_dual_method_view_html(
|
|
"60 deg", rel_60_std, rel_60_opd, rms_60_std, rms_60_opd, cfg,
|
|
is_relative=True, ref_title="20 deg")
|
|
path_60 = output_dir / f"zernike_{timestamp}_60_vs_20.html"
|
|
path_60.write_text(html_60, encoding='utf-8')
|
|
html_files.append(path_60)
|
|
|
|
# Lateral map for 60 deg
|
|
html_60_lat = self._generate_lateral_map_html("60 deg", data_60_opd, cfg)
|
|
path_60_lat = output_dir / f"zernike_{timestamp}_60_lateral.html"
|
|
path_60_lat.write_text(html_60_lat, encoding='utf-8')
|
|
html_files.append(path_60_lat)
|
|
|
|
summary['60_vs_20_filtered_rms_std'] = rms_60_std['filtered_rms']
|
|
summary['60_vs_20_filtered_rms_opd'] = rms_60_opd['filtered_rms']
|
|
|
|
# =========================================
|
|
# 90 deg Manufacturing (absolute, with dual method toggle)
|
|
# =========================================
|
|
rms_90_std = self._compute_metrics(data_90_std['X'], data_90_std['Y'], data_90_std['WFE'],
|
|
n_modes, filter_orders)
|
|
rms_90_opd = self._compute_metrics(data_90_opd['X'], data_90_opd['Y'], data_90_opd['WFE'],
|
|
n_modes, filter_orders)
|
|
|
|
html_90 = self._generate_dual_method_view_html(
|
|
"90 deg (Manufacturing)", data_90_std, data_90_opd, rms_90_std, rms_90_opd, cfg,
|
|
is_relative=False)
|
|
path_90 = output_dir / f"zernike_{timestamp}_90_mfg.html"
|
|
path_90.write_text(html_90, encoding='utf-8')
|
|
html_files.append(path_90)
|
|
|
|
# Lateral map for 90 deg
|
|
html_90_lat = self._generate_lateral_map_html("90 deg (Manufacturing)", data_90_opd, cfg)
|
|
path_90_lat = output_dir / f"zernike_{timestamp}_90_mfg_lateral.html"
|
|
path_90_lat.write_text(html_90_lat, encoding='utf-8')
|
|
html_files.append(path_90_lat)
|
|
|
|
summary['90_mfg_filtered_rms_std'] = rms_90_std['filtered_rms']
|
|
summary['90_mfg_filtered_rms_opd'] = rms_90_opd['filtered_rms']
|
|
summary['90_optician_workload'] = rms_90_opd['rms_filter_j1to3']
|
|
|
|
# Lateral displacement summary
|
|
lateral_40 = data_40_opd.get('lateral_disp', np.zeros(1)) * 1000.0 # mm to µm
|
|
lateral_60 = data_60_opd.get('lateral_disp', np.zeros(1)) * 1000.0
|
|
lateral_90 = data_90_opd.get('lateral_disp', np.zeros(1)) * 1000.0
|
|
summary['lateral_40_max_um'] = float(np.max(lateral_40))
|
|
summary['lateral_60_max_um'] = float(np.max(lateral_60))
|
|
summary['lateral_90_max_um'] = float(np.max(lateral_90))
|
|
|
|
return InsightResult(
|
|
success=True,
|
|
html_path=html_files[0], # Return first as primary
|
|
summary={
|
|
'html_files': [str(p) for p in html_files],
|
|
**summary
|
|
}
|
|
)
|