diff --git a/tools/generate_optical_report.py b/tools/generate_optical_report.py new file mode 100644 index 00000000..d0bffd4f --- /dev/null +++ b/tools/generate_optical_report.py @@ -0,0 +1,1223 @@ +#!/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, +} + + +# ============================================================================ +# 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, + } + + +# ============================================================================ +# HTML Report Generation +# ============================================================================ + +def status_badge(value, target, unit='nm'): + """Return pass/fail badge HTML.""" + if value <= target: + return f'✅ {value:.2f} {unit} ≤ {target:.1f}' + ratio = value / target + if ratio < 1.5: + return f'⚠️ {value:.2f} {unit} ({ratio:.1f}× target)' + return f'❌ {value:.2f} {unit} ({ratio:.1f}× target)' + + +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"), thickness=12, len=0.5, tickformat=".1f"), + 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='white', 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="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*3.0, max_amp*3.0], + 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), + ), + margin=dict(t=30, b=10, l=10, r=10), + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(0,0,0,0)', + font=dict(color='#e0e0e0'), + height=500, + width=700, + ) + 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=30): + """Create horizontal bar chart of Zernike coefficient magnitudes.""" + n = min(len(coeffs), max_modes) + labels = [zernike_label(j) for j in range(1, n+1)] + vals = np.abs(coeffs[:n]) + + fig = go.Figure(go.Bar( + x=vals, y=labels, orientation='h', + marker_color='#6366f1', + hovertemplate="%{y}
|c| = %{x:.3f} nm", + )) + fig.update_layout( + height=max(400, n*22), + margin=dict(t=30, b=10, l=200, r=20), + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(17,24,39,0.8)', + font=dict(color='#e0e0e0', size=10), + xaxis=dict(title="|Coefficient| (nm)", gridcolor='rgba(128,128,128,0.2)'), + yaxis=dict(autorange='reversed'), + ) + 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() + + # Plot each mode group + colors = ['#f59e0b', '#ef4444', '#10b981', '#6366f1', '#ec4899', '#14b8a6', '#f97316'] + 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 + + # RSS of modes in this group at each angle + rss = np.sqrt(np.sum(coefficients_relative[:, indices]**2, axis=1)) + color = colors[color_idx % len(colors)] + + fig.add_trace(go.Scatter( + x=angles, y=rss, + mode='lines+markers', + name=MODE_NAMES.get(group_name, group_name), + line=dict(color=color, width=2), + marker=dict(size=8), + hovertemplate=f"{group_name}
%{{x}}°: %{{y:.2f}} nm" + )) + color_idx += 1 + + fig.update_layout( + height=400, + margin=dict(t=30, b=40, l=60, r=20), + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(17,24,39,0.8)', + font=dict(color='#e0e0e0'), + xaxis=dict(title="Elevation Angle (°)", gridcolor='rgba(128,128,128,0.2)', + tickvals=angles, dtick=10), + yaxis=dict(title="RMS (nm)", gridcolor='rgba(128,128,128,0.2)'), + legend=dict(x=0.02, y=0.98, bgcolor='rgba(17,24,39,0.7)'), + ) + 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 = [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 θ)', marker_color='#f59e0b', + hovertemplate="%{y}
Axial: %{x:.3f} nm/unit" + )) + fig.add_trace(go.Bar( + y=labels, x=lateral, orientation='h', + name='Lateral (cos θ)', marker_color='#6366f1', + hovertemplate="%{y}
Lateral: %{x:.3f} nm/unit" + )) + fig.update_layout( + barmode='group', + height=max(300, len(modes)*40), + margin=dict(t=30, b=40, l=200, r=20), + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(17,24,39,0.8)', + font=dict(color='#e0e0e0', size=11), + xaxis=dict(title="Sensitivity (nm per load fraction)", gridcolor='rgba(128,128,128,0.2)'), + yaxis=dict(autorange='reversed'), + legend=dict(x=0.6, y=0.98, bgcolor='rgba(17,24,39,0.7)'), + ) + 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}° vs {ref_angle}°" for a in angles] + + fig = go.Figure(go.Bar( + x=labels, y=rms_vals, + marker_color=['#10b981' if v < 10 else '#f59e0b' if v < 20 else '#ef4444' for v in rms_vals], + text=[f"{v:.2f} nm" for v in rms_vals], + textposition='outside', + hovertemplate="%{x}: %{y:.2f} nm" + )) + fig.update_layout( + height=350, + margin=dict(t=30, b=40, l=60, r=20), + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(17,24,39,0.8)', + font=dict(color='#e0e0e0'), + yaxis=dict(title="Filtered RMS WFE (nm)", gridcolor='rgba(128,128,128,0.2)'), + ) + 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") + + # 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 = "°" if "angle" in k else "mm" + rows += f"{k}{v:.4f} {unit}\n" + params_html = f""" +
+

🔧 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}° + {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²
+
{traj_result['linear_fit_r2']:.4f}
+
+
+

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

+

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

+ """ + + # Manufacturing details + mfg_html = f""" + + + + + + + + + + +
MetricAbsolute 90°Correction (90°−20°)
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−J3 Filtered RMS{r90['rms_abs']['rms_j1to3']:.2f} nm{mfg_rms_j1to3:.2f} nm
+ """ + + # Assemble full HTML + html = f""" + + + + +{title} + + + + +
+ + +
+
+

🔭 {title}

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

📋 Executive Summary

+
+
+
WFE 40° vs 20° (Tracking)
+
{wfe_40_20:.2f} nm
+
{status_badge(wfe_40_20, targets['wfe_40_20'])}
+
+
+
WFE 60° vs 20° (Tracking)
+
{wfe_60_20:.2f} nm
+
{status_badge(wfe_60_20, targets['wfe_60_20'])}
+
+
+
MFG 90° (J1−J3 Filtered)
+
{mfg_90:.2f} nm
+
{status_badge(mfg_90, targets['mfg_90'])}
+
+
+
Weighted Sum (6·W40 + 5·W60 + 3·MFG)
+
{ws:.1f}
+
Lower is better
+
+
+ {'

Annular aperture: inner radius = ' + f'{inner_radius:.1f} mm (ø{2*inner_radius:.1f} mm central hole)' + '

' if inner_radius else ''} +
+ + +
+

📊 Per-Angle RMS Summary

+ {per_angle_plot} + + + + + + + + + {angle_detail_rows} +
AngleAbs Global RMSAbs Filtered RMSRel Filtered RMSAstigmatismComaTrefoilSpherical
+

All values in nm. Filtered = J1−J4 removed. Relative = vs 20° reference. Aberrations are absolute.

+
+ + +
+

🌊 Wavefront Error Surface Maps

+

3D residual surfaces after removing piston, tip, tilt, and defocus (J1−J4). Interactive — drag to rotate.

+
+
+

40° vs 20° (Relative)

+ {surf_40} +
+
+

60° vs 20° (Relative)

+ {surf_60} +
+
+
+

90° Manufacturing (Absolute)

+ {surf_90} +
+
+ + +{'

📈 Zernike Trajectory Analysis

' + + '

Mode-specific integrated RMS across the operating elevation range. ' + + 'The linear model cj(θ) = aj·Δsinθ + bj·Δcosθ 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 ''} + + +
+

🏭 Manufacturing Analysis (90° Orientation)

+

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

+ {mfg_html} +
+ + +{params_html} + + +
+

🔬 Zernike Coefficient Details

+
+ 40° vs 20° — Relative Coefficients +
{bar_40}
+
+
+ 60° vs 20° — Relative Coefficients +
{bar_60}
+
+
+ 90° — Absolute Coefficients +
{bar_90}
+
+
+ + +
+

📝 Methodology

+ + + + + + + + + + + {'' if traj_result else ''} + +
Zernike Modes{N_MODES} (Noll convention)
Filtered ModesJ1−J4 (Piston, Tip, Tilt, Defocus)
WFE CalculationWFE = 2 × Surface Error (reflective)
Displacement Unit{DISP_UNIT} → nm ({NM_SCALE:.0e}×)
Aperture{'Annular (inner R = ' + f'{inner_radius:.1f} mm)' if inner_radius else 'Full disk'}
Reference Angle20° (polishing/measurement orientation)
MFG Objective90°−20° relative, J1−J3 filtered (optician workload)
Weighted Sum6×WFE(40−20) + 5×WFE(60−20) + 3×MFG(90)
Trajectory R²' + f'{traj_result["linear_fit_r2"]:.6f}' + '
+
+ + +
+ Generated by Atomizer Optical Report Generator  |  {timestamp}
+ © Atomaste  |  atomaste.ca +
+ +
+ +""" + + # 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()