#!/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 from scipy.interpolate import griddata from scipy.fft import fft2, fftfreq # 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_surface_psd(X, Y, Z, aperture_radius): """Compute PSD and band RMS of surface height data (Tony Hull methodology). Interpolates scattered FEA data onto a uniform grid, applies Hann window, computes 2D FFT, and radially averages. Band RMS uses direct Parseval summation for correct dimensional results. Args: X, Y: coordinates [mm] Z: surface height [nm] aperture_radius: mirror radius [mm] Returns: dict with 'freqs', 'psd' (for plotting), 'bands' (gravity/support/hf/total RMS in nm) """ Xc = X - np.mean(X) Yc = Y - np.mean(Y) N = 256 if len(X) >= 1000 else min(128, int(np.sqrt(len(X)))) x_range = np.linspace(Xc.min(), Xc.max(), N) y_range = np.linspace(Yc.min(), Yc.max(), N) X_grid, Y_grid = np.meshgrid(x_range, y_range) Z_grid = griddata((Xc, Yc), Z, (X_grid, Y_grid), method='cubic') Z_grid = np.nan_to_num(Z_grid, nan=0.0) # Circular aperture mask R_grid = np.sqrt(X_grid**2 + Y_grid**2) apt_mask = R_grid <= aperture_radius Z_grid[~apt_mask] = 0.0 # Count valid aperture pixels for RMS normalization n_apt = int(np.sum(apt_mask)) if n_apt < 10: raise ValueError("Too few aperture pixels") # Hann window (reduces spectral leakage at edges) hann = np.outer(np.hanning(N), np.hanning(N)) Z_windowed = Z_grid * hann fft_result = fft2(Z_windowed) raw_power = np.abs(fft_result)**2 # Build radial frequency grid in cycles/aperture dx = x_range[1] - x_range[0] dy = y_range[1] - y_range[0] freqs_x = fftfreq(N, dx) freqs_y = fftfreq(N, dy) fy, fx = np.meshgrid(freqs_y, freqs_x) D = 2 * aperture_radius freqs_radial = np.sqrt(fx**2 + fy**2) * D # cycles/aperture # ── Band RMS via Parseval ── # Parseval: mean(|z|²) = (1/N⁴) Σ|FFT|² # But z is windowed, so we correct by the Hann window power: # hann_power = mean(hann²) ≈ 0.375 for 2D Hann hann_power = float(np.mean(hann[apt_mask]**2)) norm = 1.0 / (N * N * N * N * hann_power) if hann_power > 0 else 0 def _band_rms(lo_cpa, hi_cpa): m = (freqs_radial >= lo_cpa) & (freqs_radial < hi_cpa) if not np.any(m): return 0.0 return float(np.sqrt(np.sum(raw_power[m]) * norm)) bands = { 'gravity_rms': _band_rms(0.1, 2.0), 'support_rms': _band_rms(2.0, 20.0), 'hf_rms': _band_rms(20.0, N / 2), 'total_rms': float(np.sqrt(np.sum(raw_power) * norm)), } # ── Radial PSD curve for plotting (arbitrary-unit, shape matters) ── psd_norm = dx * dy / (N * N) bin_edges = np.logspace(-1.5, np.log10(N / 2), 60) freqs_center, psd_values = [], [] for i in range(len(bin_edges) - 1): mask = (freqs_radial >= bin_edges[i]) & (freqs_radial < bin_edges[i + 1]) if np.any(mask): avg = float(np.mean(raw_power[mask]) * psd_norm) if avg > 0: psd_values.append(avg) freqs_center.append(float(np.sqrt(bin_edges[i] * bin_edges[i + 1]))) return { 'freqs': np.array(freqs_center), 'psd': np.array(psd_values), 'bands': bands, } def make_psd_plot(psd_data_dict, title="Power Spectral Density"): """Create a log-log PSD plot for multiple angles. Args: psd_data_dict: {label: (freqs, psd)} for each angle/condition """ colors = {'40° vs 20°': '#2563eb', '60° vs 20°': '#dc2626', '90° (Abs)': '#16a34a', '20° (Abs)': '#64748b'} fig = go.Figure() for label, (freqs, psd) in psd_data_dict.items(): valid = psd > 0 fig.add_trace(go.Scatter( x=freqs[valid], y=psd[valid], mode='lines', name=label, line=dict(color=colors.get(label, '#6366f1'), width=2), hovertemplate="%{x:.1f} cyc/apt: %{y:.2e}" + label + "", )) # Band annotations fig.add_vrect(x0=0.1, x1=2.0, fillcolor='rgba(59,130,246,0.08)', line_width=0, layer='below', annotation_text="Gravity", annotation_position="top left", annotation=dict(font=dict(size=10, color='#3b82f6'))) fig.add_vrect(x0=2.0, x1=20.0, fillcolor='rgba(245,158,11,0.08)', line_width=0, layer='below', annotation_text="Support", annotation_position="top left", annotation=dict(font=dict(size=10, color='#f59e0b'))) fig.add_vrect(x0=20.0, x1=200.0, fillcolor='rgba(239,68,68,0.06)', line_width=0, layer='below', annotation_text="High Freq", annotation_position="top left", annotation=dict(font=dict(size=10, color='#ef4444'))) fig.update_layout( height=500, width=1100, margin=dict(t=30, b=60, l=80, r=30), **_PLOTLY_LIGHT_LAYOUT, xaxis=dict(type='log', title=dict(text="Spatial Frequency [cycles/aperture]", font=dict(color='#1e293b')), gridcolor='#e2e8f0', tickfont=dict(color='#475569')), yaxis=dict(type='log', title=dict(text="PSD [nm²·mm²]", font=dict(color='#1e293b')), gridcolor='#e2e8f0', tickfont=dict(color='#475569')), legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.9)', bordercolor='#e2e8f0', borderwidth=1, font=dict(size=11, color='#1e293b')), ) return fig.to_html(include_plotlyjs=False, full_html=False, div_id="psd_plot") def _psd_summary_html(band_dict): """Generate an HTML summary card for PSD band RMS values.""" m = band_dict total = m['total_rms'] grav_pct = 100 * m['gravity_rms'] / total if total > 0 else 0 supp_pct = 100 * m['support_rms'] / total if total > 0 else 0 hf_pct = 100 * m['hf_rms'] / total if total > 0 else 0 return f"""

PSD Band Decomposition (Tony Hull Methodology)

Gravity Signature
0.1\u20132 cyc/apt
{m['gravity_rms']:.2f} nm
{grav_pct:.0f}% of total
Support Print-through
2\u201320 cyc/apt
{m['support_rms']:.2f} nm
{supp_pct:.0f}% of total
High Frequency
>20 cyc/apt
{m['hf_rms']:.2f} nm
{hf_pct:.0f}% of total
""" # ============================================================================ # 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") # PSD analysis (Tony Hull methodology) print("\n[PSD] Computing power spectral density...") R_outer_est = float(np.max(np.hypot( angle_results[40]['X'] - np.mean(angle_results[40]['X']), angle_results[40]['Y'] - np.mean(angle_results[40]['Y'])))) psd_plot_data = {} psd_bands = {} # Use Zernike-filtered residual surface (J1-J4 removed) for PSD — # avoids piston/tilt/defocus dominating and circular-to-square boundary artifacts for ang_label, ang_key, use_rel in [ ('40° vs 20°', 40, True), ('60° vs 20°', 60, True), ('90° (Abs)', 90, False), ]: r = angle_results[ang_key] rms_data = r['rms_rel'] if use_rel else r['rms_abs'] Xp = r['X_rel'] if use_rel else r['X'] Yp = r['Y_rel'] if use_rel else r['Y'] Zp = rms_data['W_res_filt'] # J1-J4 filtered residual mask = rms_data['mask'] # Only pass masked (valid aperture) points Xm, Ym, Zm = Xp[mask], Yp[mask], Zp[mask] try: result = compute_surface_psd(Xm, Ym, Zm, R_outer_est) psd_plot_data[ang_label] = (result['freqs'], result['psd']) psd_bands[ang_label] = result['bands'] b = result['bands'] print(f" {ang_label}: gravity={b['gravity_rms']:.2f} nm, " f"support={b['support_rms']:.2f} nm, " f"total={b['total_rms']:.2f} nm") except Exception as e: print(f" [WARN] PSD for {ang_label} failed: {e}") psd_plot_html = make_psd_plot(psd_plot_data) if psd_plot_data else "" psd_summary_40 = _psd_summary_html(psd_bands['40° vs 20°']) if '40° vs 20°' in psd_bands else "" psd_summary_60 = _psd_summary_html(psd_bands['60° vs 20°']) if '60° vs 20°' in psd_bands else "" psd_summary_90 = _psd_summary_html(psd_bands['90° (Abs)']) if '90° (Abs)' in psd_bands else "" # 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_psd = 5 if traj_result else 4 sec_mfg = sec_psd + 1 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 ''} {'

' + str(sec_psd) + '. Power Spectral Density Analysis

' + '

Surface PSD computed via 2D FFT with Hann windowing and radial averaging ' + '(Tony Hull / JWST methodology). Frequency bands: Gravity signature (0.1\u20132 cyc/apt), ' + 'Support print-through (2\u201320 cyc/apt), High frequency (>20 cyc/apt).

' + '

PSD \u2014 Log-Log

' + psd_plot_html + '
' + psd_summary_40 + psd_summary_60 + psd_summary_90 + '
' if psd_plot_html 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
{bar_40}
60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients
{bar_60}
90\u00b0 \u2014 Absolute Coefficients
{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()