#!/usr/bin/env python3 """ Atomizer Zernike HTML Generator ================================ Generates 3 interactive HTML reports for Zernike wavefront analysis: 1. 40° vs 20° (relative) - Operational angle comparison 2. 60° vs 20° (relative) - Operational angle comparison 3. 90° (Manufacturing) - Absolute with manufacturing metrics Uses the rigorous OPD method from extract_zernike_figure.py which: - Accounts for lateral (X, Y) displacement via interpolation - Uses actual mesh geometry as reference (no shape assumptions) - Provides more accurate WFE for mirror optimization Usage: conda activate atomizer python zernike_html_generator.py "path/to/solution.op2" # Or with file dialog (double-click via analyze_wfe.bat) python zernike_html_generator.py Output: Creates 3 HTML files in same directory as OP2: - {basename}_40_vs_20.html - {basename}_60_vs_20.html - {basename}_90_mfg.html Author: Atomizer Created: 2025-12-19 Updated: 2025-12-28 - Upgraded to use rigorous OPD method """ import sys import os from pathlib import Path from math import factorial from datetime import datetime import numpy as np from numpy.linalg import LinAlgError # Add Atomizer root to path ATOMIZER_ROOT = Path(__file__).parent.parent if str(ATOMIZER_ROOT) not in sys.path: sys.path.insert(0, str(ATOMIZER_ROOT)) try: 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 except ImportError as e: print(f"ERROR: Missing dependency: {e}") print("Run: conda activate atomizer") sys.exit(1) # Import the rigorous OPD extractor try: from optimization_engine.extractors.extract_zernike_figure import ZernikeOPDExtractor USE_OPD_METHOD = True print("[INFO] Using rigorous OPD method (accounts for lateral displacement)") except ImportError: USE_OPD_METHOD = False print("[WARN] OPD extractor not available, falling back to simple Z-only method") # ============================================================================ # Configuration # ============================================================================ N_MODES = 50 AMP = 0.5 # visual scale for residual plot (0.5x = reduced deformation) PANCAKE = 3.0 # Z-axis range multiplier for camera view PLOT_DOWNSAMPLE = 10000 FILTER_LOW_ORDERS = 4 # piston, tip, tilt, defocus # Surface plot style COLORSCALE = 'Turbo' # Options: 'RdBu_r', 'Viridis', 'Plasma', 'Turbo', 'Jet' SURFACE_LIGHTING = True # Smooth shaded surface with lighting SHOW_ZERNIKE_BAR = True REQUIRED_SUBCASES = [90, 20, 40, 60] REF_LABEL = "2" # Reference subcase (20 deg) - using isubcase POLISH_LABEL = "1" # Manufacturing orientation (90 deg) - using isubcase # Displacement unit in OP2 -> nm scale for WFE = 2*Disp_Z DISP_SRC_UNIT = "mm" NM_PER_UNIT = 1e6 if DISP_SRC_UNIT == "mm" else 1e9 # ============================================================================ # Zernike Math # ============================================================================ def noll_indices(j: int): 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, r, th): 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 compute_zernike_coeffs(X, Y, vals, n_modes, chunk_size=100000): 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 zernike_common_name(n: int, m: int) -> str: names = { (0, 0): "Piston", (1, -1): "Tilt X", (1, 1): "Tilt Y", (2, 0): "Defocus", (2, -2): "Astig 45 deg", (2, 2): "Astig 0 deg", (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_for_j(j: int) -> str: n, m = noll_indices(j) return f"J{j:02d} - {zernike_common_name(n, m)} (n={n}, m={m})" # ============================================================================ # File I/O # ============================================================================ def find_geometry_file(op2_path: Path) -> Path: """Find matching BDF/DAT file for an 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 .dat or .bdf geometry file found for {op2_path}") def read_geometry(dat_path: Path) -> dict: bdf = BDF() bdf.read_bdf(str(dat_path)) return {int(nid): node.get_position() for nid, node in bdf.nodes.items()} def read_displacements(op2_path: Path) -> dict: """Read displacement data organized by subcase.""" op2 = OP2() op2.read_op2(str(op2_path)) if not op2.displacements: raise RuntimeError("No displacement data found in OP2 file") result = {} 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] # Get subcase identifier isubcase = getattr(darr, 'isubcase', None) label = str(isubcase) if isubcase else str(key) result[label] = { 'node_ids': node_ids.astype(int), 'disp': dmat.copy() } return result # ============================================================================ # Data Processing # ============================================================================ def build_wfe_arrays(label: str, node_ids, dmat, node_geo): """Build X, Y, WFE arrays for a subcase.""" X, Y, WFE = [], [], [] for nid, vec in zip(node_ids, dmat): geo = 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 (nm) WFE.append(wfe) return np.array(X), np.array(Y), np.array(WFE) def compute_relative_wfe(X1, Y1, WFE1, node_ids1, X2, Y2, WFE2, node_ids2): """Compute relative WFE: WFE1 - WFE2 for common nodes.""" # Build mapping for reference ref_map = {int(nid): (x, y, w) for nid, x, y, w in zip(node_ids2, X2, Y2, WFE2)} X_rel, Y_rel, WFE_rel = [], [], [] for nid, x, y, w in zip(node_ids1, X1, Y1, WFE1): nid = int(nid) if nid in ref_map: _, _, w_ref = ref_map[nid] X_rel.append(x) Y_rel.append(y) WFE_rel.append(w - w_ref) return np.array(X_rel), np.array(Y_rel), np.array(WFE_rel) def compute_rms_metrics(X, Y, W_nm): """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_LOW_ORDERS].dot(coeffs[:FILTER_LOW_ORDERS]) # J1-J3 filtered (keeping defocus - optician workload) W_res_filt_j1to3 = W_nm - Z[:, :3].dot(coeffs[:3]) 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))) return { 'coefficients': coeffs, 'R': R, 'global_rms': global_rms, 'filtered_rms': filtered_rms, 'rms_filter_j1to3': rms_filter_j1to3, 'W_res_filt': W_res_filt, } def compute_mfg_metrics(coeffs): """Manufacturing aberration magnitudes from Zernike coefficients. Noll indexing (1-based): J1=Piston, J2=TiltX, J3=TiltY, J4=Defocus, J5=Astig45, J6=Astig0, J7=ComaX, J8=ComaY, J9=TrefoilX, J10=TrefoilY, J11=Spherical Python 0-indexed: coeffs[0]=J1, coeffs[3]=J4, etc. """ # Individual mode magnitudes (RSS for paired modes) defocus = float(abs(coeffs[3])) # J4 astigmatism = float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)) # RSS(J5, J6) coma = float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)) # RSS(J7, J8) trefoil = float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) # RSS(J9, J10) spherical = float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0 # J11 # RMS of higher-order terms (J4+): sqrt(sum of squares of coefficients) # This is the proper Zernike-coefficient-based RMS excluding piston/tip/tilt higher_order_rms = float(np.sqrt(np.sum(coeffs[3:]**2))) return { 'defocus_nm': defocus, 'astigmatism_rms': astigmatism, 'coma_rms': coma, 'trefoil_rms': trefoil, 'spherical_nm': spherical, 'higher_order_rms': higher_order_rms, # RMS of all J4+ coefficients } # ============================================================================ # HTML Generation # ============================================================================ def generate_html( title: str, X: np.ndarray, Y: np.ndarray, W_nm: np.ndarray, rms_data: dict, is_relative: bool = False, ref_title: str = "20 deg", abs_pair: tuple = None, is_manufacturing: bool = False, mfg_metrics: dict = None, correction_metrics: dict = None, ) -> str: """Generate complete HTML for Zernike visualization.""" 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_for_j(j) for j in range(1, N_MODES+1)] coeff_abs = np.abs(coeffs) # Downsample for display n = len(X) if n > PLOT_DOWNSAMPLE: rng = np.random.default_rng(42) sel = rng.choice(n, size=PLOT_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 # Triangulate for smooth mesh surface mesh_traces = [] try: tri = Triangulation(Xp, Yp) if tri.triangles is not None and len(tri.triangles) > 0: i, j, k = tri.triangles.T # Create smooth shaded mesh with lighting 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, # Smooth shading (interpolated normals) 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}
Y: %{y:.1f}
Residual: %{z:.2f} nm" )) except Exception as e: print(f"Triangulation warning: {e}") # Fallback scatter only if mesh failed 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 figure layout based on content type if is_manufacturing and mfg_metrics and correction_metrics: # Manufacturing page: 5 rows 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"Surface Residual - {title}{title_suffix}", "RMS Metrics (Absolute 90 deg)", "Mode Magnitudes at 90 deg", "Pre-Correction (90 deg - 20 deg)", "|Zernike Coefficients| (nm)" ] ) elif SHOW_ZERNIKE_BAR: # Standard page with bar chart: 4 rows 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"Surface Residual - {title}{title_suffix}", "RMS Metrics", f"Zernike Coefficients ({N_MODES} modes)", "|Zernike Coefficients| (nm)" ] ) else: # Standard page without bar: 3 rows 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"Surface Residual - {title}{title_suffix}", "RMS Metrics", f"Zernike Coefficients ({N_MODES} modes)" ] ) # Add 3D mesh traces for tr in mesh_traces: fig.add_trace(tr, row=1, col=1) # Configure 3D scene with better camera angle and proportional axes fig.update_scenes( camera=dict( eye=dict(x=1.2, y=1.2, z=0.8), # Angled view from above 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) # Flatten Z for better visualization ) # RMS table (row 2) if is_relative and abs_pair: abs_global, abs_filtered = abs_pair fig.add_trace(go.Table( header=dict(values=["Metric", "Relative (nm)", "Absolute (nm)"], 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: # 90 deg absolute RMS fig.add_trace(go.Table( header=dict(values=["Metric", "Value (nm)"], 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) # Mode magnitudes (row 3) fig.add_trace(go.Table( header=dict(values=["Mode", "Value (nm)"], 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) # Pre-correction (row 4) - Aberrations to polish out (90° - 20°) # Shows what correction is needed when manufacturing at 90° to achieve 20° figure fig.add_trace(go.Table( header=dict(values=["Aberration", "Magnitude (nm)"], align="left", fill_color='#1f2937', font=dict(color='white')), cells=dict(values=[ ["Defocus (J4)", "Astigmatism (J5+J6)", "Coma (J7+J8)", "Trefoil (J9+J10)", "Spherical (J11)"], [f"{correction_metrics['defocus_nm']:.2f}", f"{correction_metrics['astigmatism_rms']:.2f}", f"{correction_metrics['coma_rms']:.2f}", f"{correction_metrics['trefoil_rms']:.2f}", f"{correction_metrics['spherical_nm']:.2f}"] ], align="left", fill_color='#374151', font=dict(color='white')) ), row=4, col=1) else: # Standard absolute table fig.add_trace(go.Table( header=dict(values=["Metric", "Value (nm)"], 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) # Zernike coefficients table (row 3 or skip if manufacturing) if not (is_manufacturing and mfg_metrics and correction_metrics): fig.add_trace(go.Table( header=dict(values=["Noll j", "Label", "|Coeff| (nm)"], 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 (last row) if SHOW_ZERNIKE_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}
|Coeff| = %{x:.3f} nm", 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"Atomizer Zernike Analysis - {title}", x=0.5, font=dict(size=18) ) ) return fig.to_html(include_plotlyjs='cdn', full_html=True) # ============================================================================ # Main # ============================================================================ def find_op2_file(working_dir=None): """Find the most recent OP2 file in the working directory.""" if working_dir is None: working_dir = Path.cwd() else: working_dir = Path(working_dir) op2_files = list(working_dir.glob("*solution*.op2")) + list(working_dir.glob("*.op2")) if not op2_files: op2_files = list(working_dir.glob("**/*solution*.op2")) if not op2_files: return None return max(op2_files, key=lambda p: p.stat().st_mtime) def main_opd(op2_path: Path): """Generate all 3 HTML files using rigorous OPD method.""" print("=" * 70) print(" ATOMIZER ZERNIKE HTML GENERATOR (OPD METHOD)") print("=" * 70) print(f"\nOP2 File: {op2_path.name}") print(f"Directory: {op2_path.parent}") print("\n[INFO] Using OPD method: accounts for lateral (X,Y) displacement") # Initialize extractor extractor = ZernikeOPDExtractor( op2_path, displacement_unit='mm', n_modes=N_MODES, filter_orders=FILTER_LOW_ORDERS ) print(f"\nAvailable subcases: {list(extractor.displacements.keys())}") # Map subcases (try common patterns) displacements = extractor.displacements subcase_map = {} if '1' in displacements and '2' in displacements: subcase_map = {'90': '1', '20': '2', '40': '3', '60': '4'} elif '90' in displacements and '20' in displacements: subcase_map = {'90': '90', '20': '20', '40': '40', '60': '60'} else: available = sorted(displacements.keys(), key=lambda x: int(x) if x.isdigit() else 0) if len(available) >= 4: subcase_map = {'90': available[0], '20': available[1], '40': available[2], '60': available[3]} print(f"[WARN] Using mapped subcases: {subcase_map}") else: print(f"[ERROR] Need 4 subcases, found: {available}") return output_dir = op2_path.parent base = op2_path.stem timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") html_files = [] # ======================================================================== # Extract absolute metrics for each subcase # ======================================================================== print("\nExtracting absolute metrics (OPD method)...") results_abs = {} for angle, label in subcase_map.items(): result = extractor.extract_subcase(label, include_coefficients=True) results_abs[angle] = result lat_disp = result.get('max_lateral_displacement_um', 0) print(f" {angle} deg: Filtered RMS = {result['filtered_rms_nm']:.2f} nm, " f"Lateral disp max = {lat_disp:.3f} um") # ======================================================================== # Extract relative metrics (40-20, 60-20, 90-20) # ======================================================================== print("\nExtracting relative metrics (OPD method)...") # 40 vs 20 result_40_rel = extractor.extract_relative(subcase_map['40'], subcase_map['20'], include_coefficients=True) print(f" 40-20: Relative Filtered RMS = {result_40_rel['relative_filtered_rms_nm']:.2f} nm") # 60 vs 20 result_60_rel = extractor.extract_relative(subcase_map['60'], subcase_map['20'], include_coefficients=True) print(f" 60-20: Relative Filtered RMS = {result_60_rel['relative_filtered_rms_nm']:.2f} nm") # 90 vs 20 (for correction metrics) result_90_rel = extractor.extract_relative(subcase_map['90'], subcase_map['20'], include_coefficients=True) print(f" 90-20: Relative Filtered RMS = {result_90_rel['relative_filtered_rms_nm']:.2f} nm") # ======================================================================== # Generate HTML files # ======================================================================== # Helper to convert OPD results to the format expected by generate_html def opd_to_rms_data(result, is_relative=False): """Convert OPD extractor result to rms_data dict for generate_html.""" coeffs = np.array(result.get('coefficients', [0] * N_MODES)) # Recompute filtered residuals for visualization # For now, use simplified approach - the metrics are correct filtered_rms = result.get('relative_filtered_rms_nm' if is_relative else 'filtered_rms_nm', 0) global_rms = result.get('relative_global_rms_nm' if is_relative else 'global_rms_nm', 0) rms_j1to3 = result.get('relative_rms_filter_j1to3' if is_relative else 'rms_filter_j1to3_nm', 0) # We need W_res_filt for visualization - extract from diagnostic data # For now, create a placeholder that will be updated return { 'coefficients': coeffs, 'R': 1.0, # Will be updated 'global_rms': global_rms, 'filtered_rms': filtered_rms, 'rms_filter_j1to3': rms_j1to3, 'W_res_filt': None, # Will compute separately for visualization } # For visualization, we need the actual WFE arrays # Get diagnostic data from extractor print("\nGenerating HTML reports...") # 40 vs 20 print(" Generating 40 deg vs 20 deg...") opd_40 = extractor._build_figure_opd_data(subcase_map['40']) opd_20 = extractor._build_figure_opd_data(subcase_map['20']) # Build relative WFE arrays ref_wfe_map = {int(nid): wfe for nid, wfe in zip(opd_20['node_ids'], opd_20['wfe_nm'])} X_40_rel, Y_40_rel, WFE_40_rel = [], [], [] for i, nid in enumerate(opd_40['node_ids']): nid = int(nid) if nid in ref_wfe_map: X_40_rel.append(opd_40['x_deformed'][i]) Y_40_rel.append(opd_40['y_deformed'][i]) WFE_40_rel.append(opd_40['wfe_nm'][i] - ref_wfe_map[nid]) X_40_rel = np.array(X_40_rel) Y_40_rel = np.array(Y_40_rel) WFE_40_rel = np.array(WFE_40_rel) rms_40_rel = compute_rms_metrics(X_40_rel, Y_40_rel, WFE_40_rel) rms_40_abs = compute_rms_metrics(opd_40['x_deformed'], opd_40['y_deformed'], opd_40['wfe_nm']) html_40 = generate_html( title="40 deg (OPD)", X=X_40_rel, Y=Y_40_rel, W_nm=WFE_40_rel, rms_data=rms_40_rel, is_relative=True, ref_title="20 deg", abs_pair=(rms_40_abs['global_rms'], rms_40_abs['filtered_rms']) ) path_40 = output_dir / f"{base}_{timestamp}_40_vs_20.html" path_40.write_text(html_40, encoding='utf-8') html_files.append(path_40) print(f" Created: {path_40.name}") # 60 vs 20 print(" Generating 60 deg vs 20 deg...") opd_60 = extractor._build_figure_opd_data(subcase_map['60']) X_60_rel, Y_60_rel, WFE_60_rel = [], [], [] for i, nid in enumerate(opd_60['node_ids']): nid = int(nid) if nid in ref_wfe_map: X_60_rel.append(opd_60['x_deformed'][i]) Y_60_rel.append(opd_60['y_deformed'][i]) WFE_60_rel.append(opd_60['wfe_nm'][i] - ref_wfe_map[nid]) X_60_rel = np.array(X_60_rel) Y_60_rel = np.array(Y_60_rel) WFE_60_rel = np.array(WFE_60_rel) rms_60_rel = compute_rms_metrics(X_60_rel, Y_60_rel, WFE_60_rel) rms_60_abs = compute_rms_metrics(opd_60['x_deformed'], opd_60['y_deformed'], opd_60['wfe_nm']) html_60 = generate_html( title="60 deg (OPD)", X=X_60_rel, Y=Y_60_rel, W_nm=WFE_60_rel, rms_data=rms_60_rel, is_relative=True, ref_title="20 deg", abs_pair=(rms_60_abs['global_rms'], rms_60_abs['filtered_rms']) ) path_60 = output_dir / f"{base}_{timestamp}_60_vs_20.html" path_60.write_text(html_60, encoding='utf-8') html_files.append(path_60) print(f" Created: {path_60.name}") # 90 deg Manufacturing print(" Generating 90 deg Manufacturing...") opd_90 = extractor._build_figure_opd_data(subcase_map['90']) rms_90 = compute_rms_metrics(opd_90['x_deformed'], opd_90['y_deformed'], opd_90['wfe_nm']) mfg_metrics = compute_mfg_metrics(rms_90['coefficients']) # 90-20 relative for correction metrics X_90_rel, Y_90_rel, WFE_90_rel = [], [], [] for i, nid in enumerate(opd_90['node_ids']): nid = int(nid) if nid in ref_wfe_map: X_90_rel.append(opd_90['x_deformed'][i]) Y_90_rel.append(opd_90['y_deformed'][i]) WFE_90_rel.append(opd_90['wfe_nm'][i] - ref_wfe_map[nid]) X_90_rel = np.array(X_90_rel) Y_90_rel = np.array(Y_90_rel) WFE_90_rel = np.array(WFE_90_rel) rms_90_rel = compute_rms_metrics(X_90_rel, Y_90_rel, WFE_90_rel) # Get all correction metrics from Zernike coefficients (90° - 20°) correction_metrics = compute_mfg_metrics(rms_90_rel['coefficients']) html_90 = generate_html( title="90 deg Manufacturing (OPD)", X=opd_90['x_deformed'], Y=opd_90['y_deformed'], W_nm=opd_90['wfe_nm'], rms_data=rms_90, is_relative=False, is_manufacturing=True, mfg_metrics=mfg_metrics, correction_metrics=correction_metrics ) path_90 = output_dir / f"{base}_{timestamp}_90_mfg.html" path_90.write_text(html_90, encoding='utf-8') html_files.append(path_90) print(f" Created: {path_90.name}") # ======================================================================== # Summary # ======================================================================== print("\n" + "=" * 70) print("SUMMARY (OPD Method)") print("=" * 70) print(f"\nGenerated {len(html_files)} HTML files:") for f in html_files: print(f" - {f.name}") print("\n" + "-" * 70) print("OPTIMIZATION OBJECTIVES (OPD Method)") print("-" * 70) print(f" 40-20 Filtered RMS: {rms_40_rel['filtered_rms']:.2f} nm") print(f" 60-20 Filtered RMS: {rms_60_rel['filtered_rms']:.2f} nm") print(f" MFG 90 (J1-J3): {rms_90_rel['rms_filter_j1to3']:.2f} nm") # Weighted sums ws_v4 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 2*rms_90_rel['rms_filter_j1to3'] ws_v5 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 3*rms_90_rel['rms_filter_j1to3'] print(f"\n V4 Weighted Sum (5/5/2): {ws_v4:.2f}") print(f" V5 Weighted Sum (5/5/3): {ws_v5:.2f}") # Lateral displacement summary print("\n" + "-" * 70) print("LATERAL DISPLACEMENT SUMMARY") print("-" * 70) for angle in ['20', '40', '60', '90']: lat = results_abs[angle].get('max_lateral_displacement_um', 0) print(f" {angle} deg: max {lat:.3f} um") print("\n" + "=" * 70) print("DONE") print("=" * 70) return html_files def main(op2_path: Path): """Generate all 3 HTML files (legacy Z-only method).""" print("=" * 70) print(" ATOMIZER ZERNIKE HTML GENERATOR") print("=" * 70) print(f"\nOP2 File: {op2_path.name}") print(f"Directory: {op2_path.parent}") # Find geometry print("\nFinding geometry file...") geo_path = find_geometry_file(op2_path) print(f"Found: {geo_path.name}") # Read data print("\nReading geometry...") node_geo = read_geometry(geo_path) print(f"Loaded {len(node_geo)} nodes") print("\nReading displacements...") displacements = read_displacements(op2_path) print(f"Found subcases: {list(displacements.keys())}") # Map subcases (try common patterns) # Pattern 1: Direct labels (1, 2, 3, 4) # Pattern 2: Angle labels (90, 20, 40, 60) subcase_map = {} if '1' in displacements and '2' in displacements: # Standard NX pattern: 1=90deg, 2=20deg, 3=40deg, 4=60deg subcase_map = {'90': '1', '20': '2', '40': '3', '60': '4'} elif '90' in displacements and '20' in displacements: # Direct angle labels subcase_map = {'90': '90', '20': '20', '40': '40', '60': '60'} else: # Try to use whatever is available available = sorted(displacements.keys(), key=lambda x: int(x) if x.isdigit() else 0) if len(available) >= 4: subcase_map = {'90': available[0], '20': available[1], '40': available[2], '60': available[3]} print(f"[WARN] Using mapped subcases: {subcase_map}") else: print(f"[ERROR] Need 4 subcases, found: {available}") return # Check all required subcases exist for angle, label in subcase_map.items(): if label not in displacements: print(f"[ERROR] Subcase '{label}' (angle {angle}) not found") return output_dir = op2_path.parent base = op2_path.stem timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") html_files = [] # ======================================================================== # Build WFE arrays for reference (20 deg) and manufacturing (90 deg) # ======================================================================== print("\nProcessing subcases...") # Reference: 20 deg ref_label = subcase_map['20'] ref_data = displacements[ref_label] X_ref, Y_ref, WFE_ref = build_wfe_arrays( '20', ref_data['node_ids'], ref_data['disp'], node_geo ) rms_ref = compute_rms_metrics(X_ref, Y_ref, WFE_ref) print(f" 20 deg: Global RMS = {rms_ref['global_rms']:.2f} nm, Filtered = {rms_ref['filtered_rms']:.2f} nm") # Manufacturing: 90 deg mfg_label = subcase_map['90'] mfg_data = displacements[mfg_label] X_90, Y_90, WFE_90 = build_wfe_arrays( '90', mfg_data['node_ids'], mfg_data['disp'], node_geo ) rms_90 = compute_rms_metrics(X_90, Y_90, WFE_90) mfg_metrics = compute_mfg_metrics(rms_90['coefficients']) print(f" 90 deg: Global RMS = {rms_90['global_rms']:.2f} nm, Filtered = {rms_90['filtered_rms']:.2f} nm") # ======================================================================== # 1. Generate 40 deg vs 20 deg (relative) # ======================================================================== print("\nGenerating 40 deg vs 20 deg...") sc_40_label = subcase_map['40'] sc_40_data = displacements[sc_40_label] X_40, Y_40, WFE_40 = build_wfe_arrays( '40', sc_40_data['node_ids'], sc_40_data['disp'], node_geo ) X_40_rel, Y_40_rel, WFE_40_rel = compute_relative_wfe( X_40, Y_40, WFE_40, sc_40_data['node_ids'], X_ref, Y_ref, WFE_ref, ref_data['node_ids'] ) rms_40_abs = compute_rms_metrics(X_40, Y_40, WFE_40) rms_40_rel = compute_rms_metrics(X_40_rel, Y_40_rel, WFE_40_rel) html_40 = generate_html( title="40 deg", X=X_40_rel, Y=Y_40_rel, W_nm=WFE_40_rel, rms_data=rms_40_rel, is_relative=True, ref_title="20 deg", abs_pair=(rms_40_abs['global_rms'], rms_40_abs['filtered_rms']) ) path_40 = output_dir / f"{base}_{timestamp}_40_vs_20.html" path_40.write_text(html_40, encoding='utf-8') html_files.append(path_40) print(f" Created: {path_40.name}") print(f" Relative: Global={rms_40_rel['global_rms']:.2f}, Filtered={rms_40_rel['filtered_rms']:.2f}") # ======================================================================== # 2. Generate 60 deg vs 20 deg (relative) # ======================================================================== print("\nGenerating 60 deg vs 20 deg...") sc_60_label = subcase_map['60'] sc_60_data = displacements[sc_60_label] X_60, Y_60, WFE_60 = build_wfe_arrays( '60', sc_60_data['node_ids'], sc_60_data['disp'], node_geo ) X_60_rel, Y_60_rel, WFE_60_rel = compute_relative_wfe( X_60, Y_60, WFE_60, sc_60_data['node_ids'], X_ref, Y_ref, WFE_ref, ref_data['node_ids'] ) rms_60_abs = compute_rms_metrics(X_60, Y_60, WFE_60) rms_60_rel = compute_rms_metrics(X_60_rel, Y_60_rel, WFE_60_rel) html_60 = generate_html( title="60 deg", X=X_60_rel, Y=Y_60_rel, W_nm=WFE_60_rel, rms_data=rms_60_rel, is_relative=True, ref_title="20 deg", abs_pair=(rms_60_abs['global_rms'], rms_60_abs['filtered_rms']) ) path_60 = output_dir / f"{base}_{timestamp}_60_vs_20.html" path_60.write_text(html_60, encoding='utf-8') html_files.append(path_60) print(f" Created: {path_60.name}") print(f" Relative: Global={rms_60_rel['global_rms']:.2f}, Filtered={rms_60_rel['filtered_rms']:.2f}") # ======================================================================== # 3. Generate 90 deg Manufacturing (absolute with correction metrics) # ======================================================================== print("\nGenerating 90 deg Manufacturing...") # Compute 90 deg - 20 deg for correction metrics X_90_rel, Y_90_rel, WFE_90_rel = compute_relative_wfe( X_90, Y_90, WFE_90, mfg_data['node_ids'], X_ref, Y_ref, WFE_ref, ref_data['node_ids'] ) rms_90_rel = compute_rms_metrics(X_90_rel, Y_90_rel, WFE_90_rel) # Get all correction metrics from Zernike coefficients (90° - 20°) correction_metrics = compute_mfg_metrics(rms_90_rel['coefficients']) html_90 = generate_html( title="90 deg (Manufacturing)", X=X_90, Y=Y_90, W_nm=WFE_90, rms_data=rms_90, is_relative=False, is_manufacturing=True, mfg_metrics=mfg_metrics, correction_metrics=correction_metrics ) path_90 = output_dir / f"{base}_{timestamp}_90_mfg.html" path_90.write_text(html_90, encoding='utf-8') html_files.append(path_90) print(f" Created: {path_90.name}") print(f" Absolute: Global={rms_90['global_rms']:.2f}, Filtered={rms_90['filtered_rms']:.2f}") print(f" Optician Workload (J1-J3): {rms_90['rms_filter_j1to3']:.2f} nm") # ======================================================================== # Summary # ======================================================================== print("\n" + "=" * 70) print("SUMMARY") print("=" * 70) print(f"\nGenerated {len(html_files)} HTML files:") for f in html_files: print(f" - {f.name}") print("\n" + "-" * 70) print("OPTIMIZATION OBJECTIVES") print("-" * 70) print(f" 40-20 Filtered RMS: {rms_40_rel['filtered_rms']:.2f} nm") print(f" 60-20 Filtered RMS: {rms_60_rel['filtered_rms']:.2f} nm") print(f" MFG 90 (J1-J3): {rms_90_rel['rms_filter_j1to3']:.2f} nm") # Weighted sums ws_v4 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 2*rms_90_rel['rms_filter_j1to3'] ws_v5 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 3*rms_90_rel['rms_filter_j1to3'] print(f"\n V4 Weighted Sum (5/5/2): {ws_v4:.2f}") print(f" V5 Weighted Sum (5/5/3): {ws_v5:.2f}") print("\n" + "=" * 70) print("DONE") print("=" * 70) return html_files if __name__ == '__main__': if len(sys.argv) > 1: op2_path = Path(sys.argv[1]) if not op2_path.exists(): print(f"ERROR: File not found: {op2_path}") sys.exit(1) else: print("No OP2 file specified, searching...") op2_path = find_op2_file() if op2_path is None: print("ERROR: No OP2 file found in current directory.") print("Usage: python zernike_html_generator.py ") sys.exit(1) print(f"Found: {op2_path}") # Check for --legacy flag to use old Z-only method use_legacy = '--legacy' in sys.argv or '--z-only' in sys.argv try: if USE_OPD_METHOD and not use_legacy: main_opd(op2_path) else: if use_legacy: print("[INFO] Using legacy Z-only method (--legacy flag)") main(op2_path) except Exception as e: print(f"\nERROR: {e}") import traceback traceback.print_exc() sys.exit(1)