#!/usr/bin/env python3 """ Atomizer Optical Performance Report Generator =============================================== Generates a comprehensive, CDR-ready HTML report for the optical performance of an M1 mirror design from FEA results (OP2 file). The report combines: 1. Executive Summary with pass/fail vs design targets 2. Per-Angle Wavefront Error Analysis (3D surface plots) 3. Zernike Trajectory Analysis (mode-specific metrics across elevation) 4. Sensitivity Matrix (axial vs lateral load response) 5. Manufacturing Analysis (90° correction metrics) 6. Full Zernike coefficient tables Usage: conda activate atomizer python generate_optical_report.py "path/to/solution.op2" # With annular aperture python generate_optical_report.py "path/to/solution.op2" --inner-radius 135.75 # Custom targets python generate_optical_report.py "path/to/solution.op2" --target-40 4.0 --target-60 10.0 --target-mfg 20.0 # Include design parameters from study database python generate_optical_report.py "path/to/solution.op2" --study-db "path/to/study.db" --trial 725 Output: Creates a single comprehensive HTML file: {basename}_OPTICAL_REPORT_{timestamp}.html Author: Atomizer / Atomaste Created: 2026-01-29 """ import sys import os import argparse import json 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 Atomizer extractors from optimization_engine.extractors.extract_zernike import ( compute_zernike_coefficients, compute_rms_metrics, compute_aberration_magnitudes, compute_rms_with_custom_filter, zernike_noll, zernike_label, zernike_name, noll_indices, read_node_geometry, find_geometry_file, extract_displacements_by_subcase, UNIT_TO_NM, DEFAULT_N_MODES, DEFAULT_FILTER_ORDERS, ) from optimization_engine.extractors.extract_zernike_trajectory import ( ZernikeTrajectoryExtractor, MODE_GROUPS, MODE_NAMES, compute_trajectory_params, ) # ============================================================================ # Configuration # ============================================================================ N_MODES = 50 FILTER_LOW_ORDERS = 4 PLOT_DOWNSAMPLE = 12000 COLORSCALE = 'Turbo' # Default design targets (nm) DEFAULT_TARGETS = { 'wfe_40_20': 4.0, 'wfe_60_20': 10.0, 'mfg_90': 20.0, } # Default annular aperture for M1 (271.5mm central hole diameter) DEFAULT_INNER_RADIUS = 135.75 # mm DISP_UNIT = 'mm' NM_SCALE = UNIT_TO_NM[DISP_UNIT] # Subcase mapping: subcase_id -> angle SUBCASE_ANGLE_MAP = { '1': 90, '2': 20, '3': 40, '4': 60, '90': 90, '20': 20, '40': 40, '60': 60, } # ── Professional light-theme Plotly layout defaults ── _PLOTLY_LIGHT_LAYOUT = dict( paper_bgcolor='#ffffff', plot_bgcolor='#f8fafc', font=dict(family="Inter, system-ui, sans-serif", color='#1e293b', size=12), ) # Professional blue palette for charts _BLUE_PALETTE = ['#2563eb', '#3b82f6', '#60a5fa', '#93c5fd', '#1d4ed8', '#1e40af'] _MODE_COLORS = ['#2563eb', '#dc2626', '#16a34a', '#9333ea', '#ea580c', '#0891b2', '#4f46e5'] _MODE_DASHES = ['solid', 'dash', 'dot', 'dashdot', 'longdash', 'longdashdot', 'solid'] # ============================================================================ # Data Extraction Helpers # ============================================================================ def load_study_params(db_path: str, trial_id: int = None) -> dict: """Load design parameters from study database.""" import sqlite3 conn = sqlite3.connect(db_path) c = conn.cursor() if trial_id is None: # Find best trial by weighted sum c.execute(''' SELECT t.trial_id, tua.key, tua.value_json FROM trials t JOIN trial_user_attributes tua ON t.trial_id = tua.trial_id WHERE t.state = 'COMPLETE' AND tua.key = 'weighted_sum' ORDER BY CAST(tua.value_json AS REAL) ASC LIMIT 1 ''') row = c.fetchone() if row: trial_id = row[0] else: conn.close() return {} # Get all attributes for the trial c.execute(''' SELECT key, value_json FROM trial_user_attributes WHERE trial_id = ? ''', (trial_id,)) attrs = {row[0]: json.loads(row[1]) for row in c.fetchall()} # Get parameters c.execute(''' SELECT tp.key, tp.value_json FROM trial_params tp WHERE tp.trial_id = ? ''', (trial_id,)) params = {row[0]: json.loads(row[1]) for row in c.fetchall()} conn.close() return { 'trial_id': trial_id, 'attributes': attrs, 'parameters': params, } def build_wfe_arrays(node_ids, disp, node_geo): """Build X, Y, WFE arrays from displacement data.""" X, Y, WFE = [], [], [] for nid, vec in zip(node_ids, disp): geo = node_geo.get(int(nid)) if geo is None: continue X.append(geo[0]) Y.append(geo[1]) WFE.append(vec[2] * 2.0 * NM_SCALE) return np.array(X), np.array(Y), np.array(WFE) def compute_relative_wfe(X1, Y1, WFE1, nids1, X2, Y2, WFE2, nids2): """Compute WFE1 - WFE2 for common nodes.""" ref_map = {int(n): w for n, w in zip(nids2, WFE2)} Xr, Yr, Wr = [], [], [] for nid, x, y, w in zip(nids1, X1, Y1, WFE1): nid = int(nid) if nid in ref_map: Xr.append(x) Yr.append(y) Wr.append(w - ref_map[nid]) return np.array(Xr), np.array(Yr), np.array(Wr) def zernike_fit(X, Y, W, n_modes=N_MODES, inner_radius=None): """Compute Zernike fit with optional annular masking.""" Xc = X - np.mean(X) Yc = Y - np.mean(Y) R_outer = float(np.max(np.hypot(Xc, Yc))) r = np.hypot(Xc, Yc) / R_outer th = np.arctan2(Yc, Xc) # Annular mask if inner_radius is not None: r_inner_norm = inner_radius / R_outer mask = (r >= r_inner_norm) & (r <= 1.0) & ~np.isnan(W) else: mask = (r <= 1.0) & ~np.isnan(W) 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 = W.astype(np.float64) for start in range(0, len(idx), 100000): sl = idx[start:start+100000] Zb = np.column_stack([zernike_noll(j, r[sl].astype(np.float32), th[sl].astype(np.float32)).astype(np.float32) for j in range(1, m+1)]) G += (Zb.T @ Zb).astype(np.float64) h += (Zb.T @ v[sl]).astype(np.float64) try: coeffs = np.linalg.solve(G, h) except LinAlgError: coeffs = np.linalg.lstsq(G, h, rcond=None)[0] # Compute residuals Z_all = np.column_stack([zernike_noll(j, r.astype(np.float32), th.astype(np.float32)) for j in range(1, m+1)]) W_low4 = Z_all[:, :FILTER_LOW_ORDERS].dot(coeffs[:FILTER_LOW_ORDERS]) W_low3 = Z_all[:, :3].dot(coeffs[:3]) W_res_j4 = W - W_low4 # J1-J4 removed W_res_j3 = W - W_low3 # J1-J3 removed global_rms = float(np.sqrt(np.mean(W[mask]**2))) filtered_rms = float(np.sqrt(np.mean(W_res_j4[mask]**2))) rms_j1to3 = float(np.sqrt(np.mean(W_res_j3[mask]**2))) return { 'coefficients': coeffs, 'R_outer': R_outer, 'global_rms': global_rms, 'filtered_rms': filtered_rms, 'rms_j1to3': rms_j1to3, 'W_res_filt': W_res_j4, 'mask': mask, 'n_masked': int(np.sum(mask)), 'n_total': len(W), } def aberration_magnitudes(coeffs): """Get individual aberration magnitudes from Zernike coefficients.""" defocus = float(abs(coeffs[3])) astig = float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)) coma = float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)) trefoil = float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) spherical = float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0 return { 'defocus': defocus, 'astigmatism': astig, 'coma': coma, 'trefoil': trefoil, 'spherical': spherical, } def compute_spatial_freq_metrics(coefficients): """Compute spatial frequency band metrics from Zernike coefficients. Decomposes the Zernike spectrum into Low Spatial Frequency (form/figure) and Mid Spatial Frequency (ripple) bands based on radial order. Args: coefficients: array of Zernike coefficients (50 modes, Noll convention) Returns: dict with band RMS values and per-radial-order breakdown """ n_modes = min(len(coefficients), 50) coeffs = np.array(coefficients[:n_modes]) # Build radial order mapping using noll_indices order_map = {} # n -> list of coeff values for j in range(1, n_modes + 1): n, m = noll_indices(j) if n not in order_map: order_map[n] = [] order_map[n].append(coeffs[j - 1]) # Band definitions (by Noll index, 1-based) # LSF: J4-J15 (n=2..4) — Low Spatial Frequency (form/figure) # MSF: J16-J50 (n=5..9) — Mid Spatial Frequency (ripple) lsf_coeffs = coeffs[3:15] # indices 3..14 → J4..J15 msf_coeffs = coeffs[15:n_modes] # indices 15..49 → J16..J50 total_coeffs = coeffs[3:n_modes] # J4..J50 (excluding piston/tilt) lsf_rms = float(np.sqrt(np.sum(lsf_coeffs**2))) msf_rms = float(np.sqrt(np.sum(msf_coeffs**2))) total_filtered_rms = float(np.sqrt(np.sum(total_coeffs**2))) lsf_msf_ratio = lsf_rms / msf_rms if msf_rms > 0 else float('inf') # Per-radial-order RMS per_order = {} for n in sorted(order_map.keys()): order_coeffs = np.array(order_map[n]) per_order[n] = float(np.sqrt(np.sum(order_coeffs**2))) return { 'lsf_rms': lsf_rms, 'msf_rms': msf_rms, 'total_filtered_rms': total_filtered_rms, 'lsf_msf_ratio': lsf_msf_ratio, 'per_order': per_order, } def _spatial_freq_html(metrics): """Generate HTML card for spatial frequency band metrics.""" m = metrics # Per-order breakdown for n=2..9 order_items = "" for n in range(2, 10): val = m['per_order'].get(n, 0.0) order_items += ( f'
' f'n={n}:' f'{val:.2f}' f'
\n' ) ratio_str = ( f"{m['lsf_msf_ratio']:.1f}\u00d7" if m['lsf_msf_ratio'] < 1000 else "\u221e" ) return f"""

Spatial Frequency Breakdown

LSF (Form)
J4\u2013J15   n\u22644
{m['lsf_rms']:.2f} nm
MSF (Ripple)
J16\u2013J50   n\u22655
{m['msf_rms']:.2f} nm
LSF / MSF
Ratio
{ratio_str}
Per-Order RMS (nm)
{order_items}
""" # ============================================================================ # HTML Report Generation # ============================================================================ def _metric_color(value, target): """Return a CSS color class based on value vs target.""" if value <= target: return 'color: #16a34a; font-weight: 700;' # green ratio = value / target if ratio < 1.5: return 'color: #d97706; font-weight: 700;' # amber return 'color: #dc2626; font-weight: 700;' # red def make_surface_plot(X, Y, W_res, mask, inner_radius=None, title="", amp=0.5, downsample=PLOT_DOWNSAMPLE): """Create a 3D surface plot of residual WFE.""" Xm, Ym, Wm = X[mask], Y[mask], W_res[mask] n = len(Xm) if n > downsample: rng = np.random.default_rng(42) sel = rng.choice(n, size=downsample, replace=False) Xp, Yp, Wp = Xm[sel], Ym[sel], Wm[sel] else: Xp, Yp, Wp = Xm, Ym, Wm res_amp = amp * Wp max_amp = float(np.max(np.abs(res_amp))) if res_amp.size else 1.0 traces = [] try: tri = Triangulation(Xp, Yp) if tri.triangles is not None and len(tri.triangles) > 0: # Filter triangles spanning central hole if inner_radius is not None: cx, cy = np.mean(X), np.mean(Y) valid = [] for t in tri.triangles: vx = Xp[t] - cx vy = Yp[t] - cy vr = np.hypot(vx, vy) if np.any(vr < inner_radius * 0.9): continue p0, p1, p2 = Xp[t] + 1j*Yp[t] if max(abs(p1-p0), abs(p2-p1), abs(p0-p2)) > 2*inner_radius: continue valid.append(t) if valid: tri_arr = np.array(valid) else: tri_arr = tri.triangles else: tri_arr = tri.triangles i, j, k = tri_arr.T 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="nm", side="right", font=dict(color='#1e293b')), thickness=12, len=0.5, tickformat=".1f", tickfont=dict(color='#1e293b')), hovertemplate="X: %{x:.1f}
Y: %{y:.1f}
Residual: %{z:.2f} nm" )) # Inner hole circle if inner_radius: theta_c = np.linspace(0, 2*np.pi, 80) traces.append(go.Scatter3d( x=cx + inner_radius*np.cos(theta_c), y=cy + inner_radius*np.sin(theta_c), z=np.zeros(80), mode='lines', line=dict(color='#64748b', width=2), name='Central Hole', showlegend=False, hoverinfo='name' )) except Exception: 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 )) fig = go.Figure(data=traces) fig.update_layout( scene=dict( camera=dict(eye=dict(x=1.2, y=1.2, z=0.8), up=dict(x=0, y=0, z=1)), xaxis=dict(title=dict(text="X (mm)", font=dict(color='#1e293b')), showgrid=True, gridcolor='#e2e8f0', showbackground=True, backgroundcolor='#f1f5f9', tickfont=dict(color='#475569')), yaxis=dict(title=dict(text="Y (mm)", font=dict(color='#1e293b')), showgrid=True, gridcolor='#e2e8f0', showbackground=True, backgroundcolor='#f1f5f9', tickfont=dict(color='#475569')), zaxis=dict(title=dict(text="Residual (nm)", font=dict(color='#1e293b')), range=[-max_amp*3.0, max_amp*3.0], showgrid=True, gridcolor='#e2e8f0', showbackground=True, backgroundcolor='#eff6ff', tickfont=dict(color='#475569')), aspectmode='manual', aspectratio=dict(x=1, y=1, z=0.4), ), margin=dict(t=30, b=10, l=10, r=10), **_PLOTLY_LIGHT_LAYOUT, height=650, width=1200, ) return fig.to_html(include_plotlyjs=False, full_html=False, div_id=f"surface_{title.replace(' ','_')}") def make_bar_chart(coeffs, title="Zernike Coefficients", max_modes=50): """Create horizontal bar chart of Zernike coefficient magnitudes.""" n = min(len(coeffs), max_modes) labels = [str(zernike_label(j))[:40] for j in range(1, n+1)] vals = np.abs(coeffs[:n]) text_vals = [f"{v:.3f}" for v in vals] fig = go.Figure(go.Bar( x=vals, y=labels, orientation='h', marker_color=_BLUE_PALETTE[0], text=text_vals, textposition='outside', textfont=dict(size=9, color='#475569'), hovertemplate="%{y}
|c| = %{x:.3f} nm", )) fig.update_layout( height=max(400, n*22), margin=dict(t=30, b=10, l=220, r=60), **_PLOTLY_LIGHT_LAYOUT, xaxis=dict(title=dict(text="|Coefficient| (nm)", font=dict(color='#1e293b')), gridcolor='#e2e8f0', zeroline=True, zerolinecolor='#cbd5e1', tickfont=dict(color='#475569')), yaxis=dict(autorange='reversed', tickfont=dict(size=10, color='#1e293b')), ) return fig.to_html(include_plotlyjs=False, full_html=False, div_id=f"bar_{title.replace(' ','_')}") def make_trajectory_plot(angles, coefficients_relative, mode_groups, sensitivity, title=""): """Create trajectory visualization: Zernike modes vs elevation angle.""" fig = go.Figure() color_idx = 0 for group_name, noll_indices in mode_groups.items(): indices = [n - 5 for n in noll_indices if 5 <= n < 5 + coefficients_relative.shape[1]] if not indices: continue color = _MODE_COLORS[color_idx % len(_MODE_COLORS)] dash = _MODE_DASHES[color_idx % len(_MODE_DASHES)] # RSS of modes in this group at each angle rss = np.sqrt(np.sum(coefficients_relative[:, indices]**2, axis=1)) display_name = MODE_NAMES.get(group_name, group_name) # Group RSS trace (thick) fig.add_trace(go.Scatter( x=angles, y=rss, mode='lines+markers', name=f"{display_name} (RSS)", line=dict(color=color, width=3, dash=dash), marker=dict(size=10, symbol='circle'), hovertemplate=f"{display_name} RSS
%{{x:.1f}}°: %{{y:.3f}} nm", legendgroup=group_name, )) # Individual mode traces (thin, same color, lighter) for idx_i, noll_idx in enumerate(noll_indices): col_idx = noll_idx - 5 if col_idx < 0 or col_idx >= coefficients_relative.shape[1]: continue mode_vals = np.abs(coefficients_relative[:, col_idx]) mode_label = f"J{noll_idx}" fig.add_trace(go.Scatter( x=angles, y=mode_vals, mode='lines+markers', name=f" {mode_label}", line=dict(color=color, width=1, dash='dot'), marker=dict(size=5, symbol='diamond'), opacity=0.5, hovertemplate=f"{mode_label}
%{{x:.1f}}°: %{{y:.3f}} nm", legendgroup=group_name, showlegend=True, )) color_idx += 1 # Check if log scale would help (values span >2 orders of magnitude) all_y = [] for trace in fig.data: all_y.extend([v for v in trace.y if v > 0]) use_log = False if all_y: y_min, y_max = min(all_y), max(all_y) if y_max > 0 and y_min > 0 and (y_max / y_min) > 100: use_log = True yaxis_cfg = dict( title=dict(text="RMS (nm)", font=dict(color='#1e293b')), gridcolor='#e2e8f0', zeroline=True, zerolinecolor='#cbd5e1', tickfont=dict(color='#475569'), ) if use_log: yaxis_cfg['type'] = 'log' yaxis_cfg['title'] = dict(text="RMS (nm) — log scale", font=dict(color='#1e293b')) fig.update_layout( height=450, margin=dict(t=30, b=50, l=70, r=20), **_PLOTLY_LIGHT_LAYOUT, xaxis=dict(title=dict(text="Elevation Angle (°)", font=dict(color='#1e293b')), gridcolor='#e2e8f0', tickvals=list(angles), dtick=10, tickfont=dict(color='#475569')), yaxis=yaxis_cfg, legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.9)', bordercolor='#e2e8f0', borderwidth=1, font=dict(size=10, color='#1e293b')), ) return fig.to_html(include_plotlyjs=False, full_html=False, div_id="trajectory_plot") def make_sensitivity_bar(sensitivity_dict): """Create stacked bar chart of axial vs lateral sensitivity per mode.""" modes = list(sensitivity_dict.keys()) axial = [sensitivity_dict[m]['axial'] for m in modes] lateral = [sensitivity_dict[m]['lateral'] for m in modes] labels = [str(MODE_NAMES.get(m, m)) for m in modes] fig = go.Figure() fig.add_trace(go.Bar( y=labels, x=axial, orientation='h', name='Axial (sin \u03b8)', marker_color='#2563eb', text=[f"{v:.3f}" for v in axial], textposition='outside', textfont=dict(size=9, color='#475569'), hovertemplate="%{y}
Axial: %{x:.3f} nm/unit" )) fig.add_trace(go.Bar( y=labels, x=lateral, orientation='h', name='Lateral (cos \u03b8)', marker_color='#7c3aed', text=[f"{v:.3f}" for v in lateral], textposition='outside', textfont=dict(size=9, color='#475569'), hovertemplate="%{y}
Lateral: %{x:.3f} nm/unit" )) fig.update_layout( barmode='group', height=max(300, len(modes)*45), margin=dict(t=30, b=40, l=180, r=60), **_PLOTLY_LIGHT_LAYOUT, xaxis=dict(title=dict(text="Sensitivity (nm per load fraction)", font=dict(color='#1e293b')), gridcolor='#e2e8f0', zeroline=True, zerolinecolor='#cbd5e1', tickfont=dict(color='#475569')), yaxis=dict(autorange='reversed', tickfont=dict(size=11, color='#1e293b')), legend=dict(x=0.55, y=0.99, bgcolor='rgba(255,255,255,0.9)', bordercolor='#e2e8f0', borderwidth=1, font=dict(color='#1e293b')), ) return fig.to_html(include_plotlyjs=False, full_html=False, div_id="sensitivity_bar") def make_per_angle_rms_plot(angle_rms_data, ref_angle=20): """Create bar chart of per-angle RMS relative to reference.""" angles = sorted(angle_rms_data.keys()) rms_vals = [angle_rms_data[a] for a in angles] labels = [f"{a}\u00b0 vs {ref_angle}\u00b0" for a in angles] fig = go.Figure(go.Bar( x=labels, y=rms_vals, marker_color=_BLUE_PALETTE[0], text=[f"{v:.2f} nm" for v in rms_vals], textposition='outside', textfont=dict(size=11, color='#1e293b'), hovertemplate="%{x}: %{y:.2f} nm" )) fig.update_layout( height=350, margin=dict(t=30, b=40, l=60, r=20), **_PLOTLY_LIGHT_LAYOUT, yaxis=dict(title=dict(text="Filtered RMS WFE (nm)", font=dict(color='#1e293b')), gridcolor='#e2e8f0', zeroline=True, zerolinecolor='#cbd5e1', tickfont=dict(color='#475569')), xaxis=dict(tickfont=dict(color='#1e293b', size=12)), ) return fig.to_html(include_plotlyjs=False, full_html=False, div_id="per_angle_rms") # ============================================================================ # Main Report Builder # ============================================================================ def generate_report( op2_path: Path, inner_radius: float = None, targets: dict = None, study_db: str = None, trial_id: int = None, title: str = "M1 Mirror Optical Performance Report", study_name: str = None, ) -> Path: """Generate comprehensive optical performance HTML report.""" targets = targets or DEFAULT_TARGETS timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") ts_file = datetime.now().strftime("%Y%m%d_%H%M%S") print("=" * 70) print(" ATOMIZER OPTICAL PERFORMANCE REPORT GENERATOR") print("=" * 70) print(f"\nOP2 File: {op2_path.name}") print(f"Inner Radius: {inner_radius} mm" if inner_radius else "Aperture: Full disk") # ------------------------------------------------------------------ # 1. Load geometry & displacement data # ------------------------------------------------------------------ print("\n[1/5] Loading data...") geo_path = find_geometry_file(op2_path) node_geo = read_node_geometry(geo_path) print(f" Geometry: {geo_path.name} ({len(node_geo)} nodes)") op2 = OP2() op2.read_op2(str(op2_path)) displacements = extract_displacements_by_subcase(op2_path) print(f" Subcases: {list(displacements.keys())}") # Map subcases to angles subcase_map = {} for angle in ['90', '20', '40', '60']: if angle in displacements: subcase_map[angle] = angle if len(subcase_map) < 4: if all(str(i) in displacements for i in range(1, 5)): subcase_map = {'90': '1', '20': '2', '40': '3', '60': '4'} print(f" Subcase map: {subcase_map}") # Also detect intermediate angles (30, 50) if present extra_angles = [] for a in ['30', '50']: if a in displacements: extra_angles.append(a) if extra_angles: print(f" Extra angles detected: {extra_angles}") # ------------------------------------------------------------------ # 2. Per-angle Zernike analysis # ------------------------------------------------------------------ print("\n[2/5] Per-angle Zernike analysis...") ref_label = subcase_map['20'] ref_data = displacements[ref_label] X_ref, Y_ref, WFE_ref = build_wfe_arrays(ref_data['node_ids'], ref_data['disp'], node_geo) # Analysis results storage angle_results = {} # angle -> {rms_data, X, Y, WFE, ...} for angle_name, label in subcase_map.items(): data = displacements[label] X, Y, WFE = build_wfe_arrays(data['node_ids'], data['disp'], node_geo) # Absolute fit rms_abs = zernike_fit(X, Y, WFE, inner_radius=inner_radius) # Relative fit (vs 20 deg reference) if angle_name != '20': Xr, Yr, Wr = compute_relative_wfe( X, Y, WFE, data['node_ids'], X_ref, Y_ref, WFE_ref, ref_data['node_ids'] ) rms_rel = zernike_fit(Xr, Yr, Wr, inner_radius=inner_radius) else: Xr, Yr, Wr = X, Y, np.zeros_like(WFE) rms_rel = {'filtered_rms': 0.0, 'rms_j1to3': 0.0, 'coefficients': np.zeros(N_MODES)} angle_results[int(angle_name)] = { 'X': X, 'Y': Y, 'WFE': WFE, 'X_rel': Xr, 'Y_rel': Yr, 'WFE_rel': Wr, 'rms_abs': rms_abs, 'rms_rel': rms_rel, 'aberrations_abs': aberration_magnitudes(rms_abs['coefficients']), 'aberrations_rel': aberration_magnitudes(rms_rel['coefficients']) if angle_name != '20' else None, } print(f" {angle_name}° - Abs Filt: {rms_abs['filtered_rms']:.2f} nm, " f"Rel Filt: {rms_rel['filtered_rms']:.2f} nm") # Extra angles (30, 50) for ea in extra_angles: data = displacements[ea] X, Y, WFE = build_wfe_arrays(data['node_ids'], data['disp'], node_geo) Xr, Yr, Wr = compute_relative_wfe( X, Y, WFE, data['node_ids'], X_ref, Y_ref, WFE_ref, ref_data['node_ids'] ) rms_abs = zernike_fit(X, Y, WFE, inner_radius=inner_radius) rms_rel = zernike_fit(Xr, Yr, Wr, inner_radius=inner_radius) angle_results[int(ea)] = { 'X': X, 'Y': Y, 'WFE': WFE, 'X_rel': Xr, 'Y_rel': Yr, 'WFE_rel': Wr, 'rms_abs': rms_abs, 'rms_rel': rms_rel, 'aberrations_abs': aberration_magnitudes(rms_abs['coefficients']), 'aberrations_rel': aberration_magnitudes(rms_rel['coefficients']), } print(f" {ea}° - Abs Filt: {rms_abs['filtered_rms']:.2f} nm, " f"Rel Filt: {rms_rel['filtered_rms']:.2f} nm") # ------------------------------------------------------------------ # 3. Trajectory analysis # ------------------------------------------------------------------ print("\n[3/5] Trajectory analysis...") traj_result = None try: traj_extractor = ZernikeTrajectoryExtractor( op2_file=op2_path, bdf_file=geo_path, reference_angle=20.0, unit=DISP_UNIT, n_modes=N_MODES, inner_radius=inner_radius, ) traj_result = traj_extractor.extract_trajectory(exclude_angles=[90.0]) print(f" R² fit: {traj_result['linear_fit_r2']:.4f}") print(f" Dominant mode: {traj_result['dominant_mode']}") print(f" Total filtered RMS: {traj_result['total_filtered_rms_nm']:.2f} nm") except Exception as e: print(f" [WARN] Trajectory analysis failed: {e}") # ------------------------------------------------------------------ # 4. Manufacturing analysis # ------------------------------------------------------------------ print("\n[4/5] Manufacturing analysis...") r90 = angle_results[90] mfg_abs_aberr = r90['aberrations_abs'] mfg_correction = aberration_magnitudes(angle_results[90]['rms_rel']['coefficients']) mfg_rms_j1to3 = r90['rms_rel']['rms_j1to3'] print(f" MFG 90 (J1-J3 filtered): {mfg_rms_j1to3:.2f} nm") print(f" Correction - Astigmatism: {mfg_correction['astigmatism']:.2f} nm, " f"Coma: {mfg_correction['coma']:.2f} nm") # ------------------------------------------------------------------ # 5. Load study params (optional) # ------------------------------------------------------------------ study_params = None if study_db: print("\n[5/5] Loading study parameters...") try: study_params = load_study_params(study_db, trial_id) print(f" Trial #{study_params.get('trial_id', '?')}") except Exception as e: print(f" [WARN] Could not load study params: {e}") else: print("\n[5/5] No study database provided (skipping design parameters)") # ------------------------------------------------------------------ # Key metrics for executive summary # ------------------------------------------------------------------ wfe_40_20 = angle_results[40]['rms_rel']['filtered_rms'] wfe_60_20 = angle_results[60]['rms_rel']['filtered_rms'] mfg_90 = mfg_rms_j1to3 # Weighted sum ws = 6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90 # ------------------------------------------------------------------ # Generate HTML # ------------------------------------------------------------------ print("\nGenerating HTML report...") # Surface plots surf_40 = make_surface_plot( angle_results[40]['X_rel'], angle_results[40]['Y_rel'], angle_results[40]['rms_rel']['W_res_filt'], angle_results[40]['rms_rel']['mask'], inner_radius=inner_radius, title="40 vs 20" ) surf_60 = make_surface_plot( angle_results[60]['X_rel'], angle_results[60]['Y_rel'], angle_results[60]['rms_rel']['W_res_filt'], angle_results[60]['rms_rel']['mask'], inner_radius=inner_radius, title="60 vs 20" ) surf_90 = make_surface_plot( angle_results[90]['X'], angle_results[90]['Y'], angle_results[90]['rms_abs']['W_res_filt'], angle_results[90]['rms_abs']['mask'], inner_radius=inner_radius, title="90 abs" ) # Bar charts bar_40 = make_bar_chart(angle_results[40]['rms_rel']['coefficients'], title="40v20 coeffs") bar_60 = make_bar_chart(angle_results[60]['rms_rel']['coefficients'], title="60v20 coeffs") bar_90 = make_bar_chart(angle_results[90]['rms_abs']['coefficients'], title="90abs coeffs") # Spatial frequency band metrics sfm_40 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[40]['rms_rel']['coefficients'])) sfm_60 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[60]['rms_rel']['coefficients'])) sfm_90 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[90]['rms_abs']['coefficients'])) # Per-angle RMS plot angle_rms_data = {} for ang in sorted(angle_results.keys()): if ang != 20: angle_rms_data[ang] = angle_results[ang]['rms_rel']['filtered_rms'] per_angle_plot = make_per_angle_rms_plot(angle_rms_data) # Trajectory & sensitivity plots traj_plot_html = "" sens_plot_html = "" if traj_result: coeffs_rel = np.array(traj_result['coefficients_relative']) traj_plot_html = make_trajectory_plot( traj_result['angles_deg'], coeffs_rel, MODE_GROUPS, traj_result['sensitivity_matrix'] ) sens_plot_html = make_sensitivity_bar(traj_result['sensitivity_matrix']) # Design parameters table params_html = "" if study_params and study_params.get('parameters'): params = study_params['parameters'] rows = "" for k, v in sorted(params.items()): unit = "\u00b0" if "angle" in k else "mm" rows += f"{k}{v:.4f} {unit}\n" params_html = f"""

7. Design Parameters (Trial #{study_params.get('trial_id', '?')})

{rows}
ParameterValue
""" # Per-angle detail table angle_detail_rows = "" for ang in sorted(angle_results.keys()): r = angle_results[ang] rel_filt = r['rms_rel']['filtered_rms'] abs_filt = r['rms_abs']['filtered_rms'] abs_glob = r['rms_abs']['global_rms'] ab = r['aberrations_abs'] angle_detail_rows += f""" {ang}\u00b0 {abs_glob:.2f}{abs_filt:.2f} {rel_filt:.2f} {ab['astigmatism']:.2f}{ab['coma']:.2f} {ab['trefoil']:.2f}{ab['spherical']:.2f} """ # Trajectory metrics table traj_metrics_html = "" if traj_result: traj_metrics_html = f"""
Coma RMS
{traj_result['coma_rms_nm']:.2f} nm
Astigmatism RMS
{traj_result['astigmatism_rms_nm']:.2f} nm
Trefoil RMS
{traj_result['trefoil_rms_nm']:.2f} nm
Spherical RMS
{traj_result['spherical_rms_nm']:.2f} nm
Total Filtered RMS
{traj_result['total_filtered_rms_nm']:.2f} nm
Linear Fit R\u00b2
{traj_result['linear_fit_r2']:.4f}

Dominant aberration mode: {MODE_NAMES.get(traj_result['dominant_mode'], traj_result['dominant_mode'])}

Mode ranking: {' \u2192 '.join(traj_result['mode_ranking'][:5])}

""" # Manufacturing details mfg_html = f"""
MetricAbsolute 90\u00b0Correction (90\u00b0\u221220\u00b0)
Defocus (J4){mfg_abs_aberr['defocus']:.2f} nm{mfg_correction['defocus']:.2f} nm
Astigmatism (J5+J6){mfg_abs_aberr['astigmatism']:.2f} nm{mfg_correction['astigmatism']:.2f} nm
Coma (J7+J8){mfg_abs_aberr['coma']:.2f} nm{mfg_correction['coma']:.2f} nm
Trefoil (J9+J10){mfg_abs_aberr['trefoil']:.2f} nm{mfg_correction['trefoil']:.2f} nm
Spherical (J11){mfg_abs_aberr['spherical']:.2f} nm{mfg_correction['spherical']:.2f} nm
J1\u2212J3 Filtered RMS{r90['rms_abs']['rms_j1to3']:.2f} nm{mfg_rms_j1to3:.2f} nm
""" # Executive summary metric styling style_40 = _metric_color(wfe_40_20, targets['wfe_40_20']) style_60 = _metric_color(wfe_60_20, targets['wfe_60_20']) style_mfg = _metric_color(mfg_90, targets['mfg_90']) # Section numbering: adjust if trajectory present sec_surface = 3 sec_traj = 4 sec_mfg = 5 if traj_result else 4 sec_params = sec_mfg + 1 sec_zernike = sec_params + 1 if (study_params and study_params.get('parameters')) else sec_mfg + 1 sec_method = sec_zernike + 1 # Assemble full HTML html = f""" {title}

{title}

Generated {timestamp}  |  OP2: {op2_path.name}
{'
Study: ' + study_name + '
' if study_name else ''}
ATOMIZER
FEA Optimization Platform

1. Executive Summary

WFE 40\u00b0 vs 20\u00b0 (Tracking)
{wfe_40_20:.2f} nm
WFE 60\u00b0 vs 20\u00b0 (Tracking)
{wfe_60_20:.2f} nm
MFG 90\u00b0 (J1\u2212J3 Filtered)
{mfg_90:.2f} nm
Weighted Sum (6\u00b7W40 + 5\u00b7W60 + 3\u00b7MFG)
{ws:.1f}
Design targets — WFE 40\u00b0\u221220\u00b0 \u2264 {targets['wfe_40_20']:.1f} nm  |  WFE 60\u00b0\u221220\u00b0 \u2264 {targets['wfe_60_20']:.1f} nm  |  MFG 90\u00b0 \u2264 {targets['mfg_90']:.1f} nm  |  Weighted sum: lower is better. {'
Annular aperture: inner radius = ' + f'{inner_radius:.1f} mm (\u00f8{2*inner_radius:.1f} mm central hole)' if inner_radius else ''}

2. Per-Angle RMS Summary

{per_angle_plot} {angle_detail_rows}
AngleAbs Global RMSAbs Filtered RMS Rel Filtered RMS AstigmatismComaTrefoilSpherical

All values in nm. Filtered = J1\u2212J4 removed. Relative = vs 20\u00b0 reference. Aberrations are absolute.

{sec_surface}. Wavefront Error Surface Maps

3D residual surfaces after removing piston, tip, tilt, and defocus (J1\u2212J4). Interactive \u2014 drag to rotate.

40\u00b0 vs 20\u00b0 (Relative)

{surf_40}

60\u00b0 vs 20\u00b0 (Relative)

{surf_60}

90\u00b0 Manufacturing (Absolute)

{surf_90}
{'

' + str(sec_traj) + '. Zernike Trajectory Analysis

' + '

Mode-specific integrated RMS across the operating elevation range. ' + 'The linear model cj(\u03b8) = aj\u00b7\u0394sin\u03b8 + bj\u00b7\u0394cos\u03b8 decomposes gravity into axial and lateral components.

' + traj_metrics_html + '
' + '

Mode RMS vs Elevation Angle

' + traj_plot_html + '
' + '

Axial vs Lateral Sensitivity

' + sens_plot_html + '
' + '
' if traj_result else ''}

{sec_mfg}. Manufacturing Analysis (90\u00b0 Orientation)

The mirror is manufactured (polished) at 90\u00b0 orientation. The "Correction" column shows the aberrations that must be polished out to achieve the 20\u00b0 operational figure.

{mfg_html}
{params_html}

{sec_zernike}. Zernike Coefficient Details

40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients
{sfm_40}{bar_40}
60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients
{sfm_60}{bar_60}
90\u00b0 \u2014 Absolute Coefficients
{sfm_90}{bar_90}

{sec_method}. Methodology

{'' if traj_result else ''}
Zernike Modes{N_MODES} (Noll convention)
Filtered ModesJ1\u2212J4 (Piston, Tip, Tilt, Defocus)
WFE CalculationWFE = 2 \u00d7 Surface Error (reflective)
Displacement Unit{DISP_UNIT} \u2192 nm ({NM_SCALE:.0e}\u00d7)
Aperture{'Annular (inner R = ' + f'{inner_radius:.1f} mm)' if inner_radius else 'Full disk'}
Reference Angle20\u00b0 (polishing/measurement orientation)
MFG Objective90\u00b0\u221220\u00b0 relative, J1\u2212J3 filtered (optician workload)
Weighted Sum6\u00d7WFE(40\u221220) + 5\u00d7WFE(60\u221220) + 3\u00d7MFG(90)
Trajectory R\u00b2' + f'{traj_result["linear_fit_r2"]:.6f}' + '
""" # Write output output_path = op2_path.parent / f"{op2_path.stem}_OPTICAL_REPORT_{ts_file}.html" output_path.write_text(html, encoding='utf-8') print(f"\n{'=' * 70}") print(f"REPORT GENERATED: {output_path.name}") print(f"{'=' * 70}") print(f"\nLocation: {output_path}") print(f"Size: {output_path.stat().st_size / 1024:.0f} KB") return output_path # ============================================================================ # CLI # ============================================================================ def main(): parser = argparse.ArgumentParser( description='Atomizer Optical Performance Report Generator', epilog='Generates a comprehensive CDR-ready HTML report from FEA results.' ) parser.add_argument('op2_file', nargs='?', help='Path to OP2 results file') parser.add_argument('--inner-radius', '-r', type=float, default=None, help=f'Inner radius of central hole in mm (default: {DEFAULT_INNER_RADIUS}mm for M1)') parser.add_argument('--inner-diameter', '-d', type=float, default=None, help='Inner diameter of central hole in mm') parser.add_argument('--no-annular', action='store_true', help='Disable annular aperture (treat as full disk)') parser.add_argument('--target-40', type=float, default=DEFAULT_TARGETS['wfe_40_20'], help=f'WFE 40-20 target in nm (default: {DEFAULT_TARGETS["wfe_40_20"]})') parser.add_argument('--target-60', type=float, default=DEFAULT_TARGETS['wfe_60_20'], help=f'WFE 60-20 target in nm (default: {DEFAULT_TARGETS["wfe_60_20"]})') parser.add_argument('--target-mfg', type=float, default=DEFAULT_TARGETS['mfg_90'], help=f'MFG 90 target in nm (default: {DEFAULT_TARGETS["mfg_90"]})') parser.add_argument('--study-db', type=str, default=None, help='Path to study.db for design parameters') parser.add_argument('--trial', type=int, default=None, help='Trial ID (default: best trial)') parser.add_argument('--title', type=str, default="M1 Mirror Optical Performance Report", help='Report title') parser.add_argument('--study-name', type=str, default=None, help='Study name for report header') args = parser.parse_args() # Find OP2 file if args.op2_file: op2_path = Path(args.op2_file) if not op2_path.exists(): print(f"ERROR: File not found: {op2_path}") sys.exit(1) else: # Search current directory cwd = Path.cwd() candidates = list(cwd.glob("*solution*.op2")) + list(cwd.glob("*.op2")) if not candidates: print("ERROR: No OP2 file found. Specify path as argument.") sys.exit(1) op2_path = max(candidates, key=lambda p: p.stat().st_mtime) print(f"Found: {op2_path}") # Handle inner radius inner_radius = DEFAULT_INNER_RADIUS # Default to M1 annular if args.no_annular: inner_radius = None elif args.inner_diameter is not None: inner_radius = args.inner_diameter / 2.0 elif args.inner_radius is not None: inner_radius = args.inner_radius targets = { 'wfe_40_20': args.target_40, 'wfe_60_20': args.target_60, 'mfg_90': args.target_mfg, } try: generate_report( op2_path=op2_path, inner_radius=inner_radius, targets=targets, study_db=args.study_db, trial_id=args.trial, title=args.title, study_name=args.study_name, ) except Exception as e: print(f"\nERROR: {e}") import traceback traceback.print_exc() sys.exit(1) if __name__ == '__main__': main()