2026-01-29 18:28:10 +00:00
#!/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 ( 3 D 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
2026-01-29 22:15:42 +00:00
from scipy . interpolate import griddata
from scipy . fft import fft2 , fftfreq
2026-01-29 18:28:10 +00:00
# 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 ,
}
2026-01-29 20:20:01 +00:00
# ── 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 ' ]
2026-02-08 19:27:56 -05:00
# High-resolution PNG export settings
PNG_EXPORT_SCALE = 4 # 4x resolution (e.g., 700px width -> 2800px export)
PNG_EXPORT_FORMAT = ' png '
def get_plotly_config ( filename_prefix = " plot " ) :
""" Get Plotly config with high-resolution PNG export settings. """
return {
' toImageButtonOptions ' : {
' format ' : PNG_EXPORT_FORMAT ,
' filename ' : filename_prefix ,
' height ' : None , # Use current height
' width ' : None , # Use current width
' scale ' : PNG_EXPORT_SCALE , # 4x resolution multiplier
} ,
' displaylogo ' : False ,
' modeBarButtonsToAdd ' : [ ' hoverClosest3d ' ] ,
}
2026-01-29 18:28:10 +00:00
# ============================================================================
# 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 ,
}
2026-01-29 22:15:42 +00:00
def compute_surface_psd ( X , Y , Z , aperture_radius ) :
2026-01-29 23:49:03 +00:00
""" Compute PSD and band RMS of surface height data (Tony Hull methodology).
2026-01-29 20:46:58 +00:00
2026-01-29 23:49:03 +00:00
Interpolates scattered FEA data onto a uniform grid , applies Hann window ,
computes 2 D FFT , and radially averages . Band RMS uses direct Parseval
summation for correct dimensional results .
2026-01-29 20:46:58 +00:00
Args :
2026-01-29 23:49:03 +00:00
X , Y : coordinates [ mm ]
Z : surface height [ nm ]
aperture_radius : mirror radius [ mm ]
2026-01-29 20:46:58 +00:00
Returns :
2026-01-29 23:49:03 +00:00
dict with ' freqs ' , ' psd ' ( for plotting ) , ' bands ' ( gravity / support / hf / total RMS in nm )
2026-01-29 20:46:58 +00:00
"""
2026-01-29 22:15:42 +00:00
Xc = X - np . mean ( X )
Yc = Y - np . mean ( Y )
2026-01-29 20:46:58 +00:00
2026-01-29 23:49:03 +00:00
N = 256 if len ( X ) > = 1000 else min ( 128 , int ( np . sqrt ( len ( X ) ) ) )
2026-01-29 22:15:42 +00:00
2026-01-29 23:49:03 +00:00
x_range = np . linspace ( Xc . min ( ) , Xc . max ( ) , N )
y_range = np . linspace ( Yc . min ( ) , Yc . max ( ) , N )
2026-01-29 22:15:42 +00:00
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 )
2026-01-29 23:49:03 +00:00
apt_mask = R_grid < = aperture_radius
Z_grid [ ~ apt_mask ] = 0.0
2026-01-29 22:15:42 +00:00
2026-01-29 23:49:03 +00:00
# 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 ) )
2026-01-29 22:15:42 +00:00
Z_windowed = Z_grid * hann
fft_result = fft2 ( Z_windowed )
2026-01-29 23:49:03 +00:00
raw_power = np . abs ( fft_result ) * * 2
# Build radial frequency grid in cycles/aperture
2026-01-29 22:15:42 +00:00
dx = x_range [ 1 ] - x_range [ 0 ]
dy = y_range [ 1 ] - y_range [ 0 ]
2026-01-29 23:49:03 +00:00
freqs_x = fftfreq ( N , dx )
freqs_y = fftfreq ( N , dy )
2026-01-29 22:15:42 +00:00
fy , fx = np . meshgrid ( freqs_y , freqs_x )
2026-01-29 23:49:03 +00:00
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 ) )
2026-01-29 22:15:42 +00:00
2026-01-29 23:49:03 +00:00
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 ) ) ,
}
2026-01-29 22:15:42 +00:00
2026-01-29 23:49:03 +00:00
# ── 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 )
2026-01-29 22:15:42 +00:00
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 ) :
2026-01-29 23:49:03 +00:00
avg = float ( np . mean ( raw_power [ mask ] ) * psd_norm )
2026-01-29 22:15:42 +00:00
if avg > 0 :
psd_values . append ( avg )
freqs_center . append ( float ( np . sqrt ( bin_edges [ i ] * bin_edges [ i + 1 ] ) ) )
2026-01-29 20:46:58 +00:00
return {
2026-01-29 23:49:03 +00:00
' freqs ' : np . array ( freqs_center ) ,
' psd ' : np . array ( psd_values ) ,
' bands ' : bands ,
2026-01-29 20:46:58 +00:00
}
2026-01-29 22:15:42 +00:00
def make_psd_plot ( psd_data_dict , title = " Power Spectral Density " ) :
""" Create a log-log PSD plot for multiple angles.
2026-01-29 20:46:58 +00:00
2026-01-29 22:15:42 +00:00
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 ( )
2026-01-30 00:03:38 +00:00
# Determine axis range from data
all_freqs = [ ]
all_psd = [ ]
2026-01-29 22:15:42 +00:00
for label , ( freqs , psd ) in psd_data_dict . items ( ) :
valid = psd > 0
2026-01-30 00:03:38 +00:00
f_valid , p_valid = freqs [ valid ] , psd [ valid ]
all_freqs . extend ( f_valid . tolist ( ) )
all_psd . extend ( p_valid . tolist ( ) )
2026-01-29 22:15:42 +00:00
fig . add_trace ( go . Scatter (
2026-01-30 00:03:38 +00:00
x = f_valid , y = p_valid ,
2026-01-29 22:15:42 +00:00
mode = ' lines ' , name = label ,
2026-01-30 00:03:38 +00:00
line = dict ( color = colors . get ( label , ' #6366f1 ' ) , width = 2.5 ) ,
2026-01-29 22:15:42 +00:00
hovertemplate = " % {x:.1f} cyc/apt: % {y:.2e} <extra> " + label + " </extra> " ,
) )
2026-01-30 00:03:38 +00:00
# Compute axis limits from actual data
if all_freqs :
f_min = max ( 0.05 , min ( all_freqs ) * 0.5 )
f_max = max ( all_freqs ) * 2.0
else :
f_min , f_max = 0.05 , 200.0
# Band annotations — clamped to data range
band_max = min ( f_max , 200.0 )
fig . add_vrect ( x0 = 0.1 , x1 = 2.0 , fillcolor = ' rgba(59,130,246,0.07) ' , line_width = 0 , layer = ' below ' ,
2026-01-29 22:15:42 +00:00
annotation_text = " Gravity " , annotation_position = " top left " ,
2026-01-30 00:03:38 +00:00
annotation = dict ( font = dict ( size = 11 , color = ' #3b82f6 ' ) ) )
fig . add_vrect ( x0 = 2.0 , x1 = 20.0 , fillcolor = ' rgba(245,158,11,0.07) ' , line_width = 0 , layer = ' below ' ,
2026-01-29 22:15:42 +00:00
annotation_text = " Support " , annotation_position = " top left " ,
2026-01-30 00:03:38 +00:00
annotation = dict ( font = dict ( size = 11 , color = ' #f59e0b ' ) ) )
if band_max > 20 :
fig . add_vrect ( x0 = 20.0 , x1 = band_max , fillcolor = ' rgba(239,68,68,0.05) ' , line_width = 0 , layer = ' below ' ,
annotation_text = " High Freq " , annotation_position = " top left " ,
annotation = dict ( font = dict ( size = 11 , color = ' #ef4444 ' ) ) )
2026-01-29 20:46:58 +00:00
2026-01-29 22:15:42 +00:00
fig . update_layout (
height = 500 , width = 1100 ,
margin = dict ( t = 30 , b = 60 , l = 80 , r = 30 ) ,
* * _PLOTLY_LIGHT_LAYOUT ,
2026-01-30 00:03:38 +00:00
xaxis = dict ( type = ' log ' ,
title = dict ( text = " Spatial Frequency [cycles/aperture] " , font = dict ( color = ' #1e293b ' , size = 13 ) ) ,
range = [ np . log10 ( f_min ) , np . log10 ( f_max ) ] ,
gridcolor = ' #e2e8f0 ' , tickfont = dict ( color = ' #475569 ' , size = 11 ) ,
dtick = 1 ) ,
yaxis = dict ( type = ' log ' ,
title = dict ( text = " PSD [nm \u00b2 \u00b7 mm \u00b2 ] " , font = dict ( color = ' #1e293b ' , size = 13 ) ) ,
gridcolor = ' #e2e8f0 ' , tickfont = dict ( color = ' #475569 ' , size = 11 ) ) ,
legend = dict ( x = 0.70 , y = 0.98 , bgcolor = ' rgba(255,255,255,0.92) ' ,
bordercolor = ' #e2e8f0 ' , borderwidth = 1 , font = dict ( size = 12 , color = ' #1e293b ' ) ) ,
2026-01-29 20:46:58 +00:00
)
2026-01-29 22:15:42 +00:00
return fig . to_html ( include_plotlyjs = False , full_html = False , div_id = " psd_plot " )
2026-01-29 23:58:14 +00:00
def _psd_summary_html ( band_dict , label = " " ) :
2026-01-29 22:15:42 +00:00
""" 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
2026-01-29 20:46:58 +00:00
return f """
< div class = " sf-breakdown " >
2026-01-29 23:58:14 +00:00
< h4 > { ' PSD Band Decomposition — ' + label if label else ' PSD Band Decomposition (Tony Hull Methodology) ' } < / h4 >
2026-01-29 20:46:58 +00:00
< div class = " sf-band-grid " >
< div class = " sf-band-card sf-lsf " >
2026-01-29 22:15:42 +00:00
< div class = " sf-band-label " > Gravity Signature < / div >
< div class = " sf-band-range " > 0.1 \u20132 cyc / apt < / div >
< div class = " sf-band-value " > { m [ ' gravity_rms ' ] : .2 f } < span class = " sf-unit " > nm < / span > < / div >
< div class = " sf-band-range " > { grav_pct : .0 f } % of total < / div >
2026-01-29 20:46:58 +00:00
< / div >
< div class = " sf-band-card sf-msf " >
2026-01-29 22:15:42 +00:00
< div class = " sf-band-label " > Support Print - through < / div >
< div class = " sf-band-range " > 2 \u201320 cyc / apt < / div >
< div class = " sf-band-value " > { m [ ' support_rms ' ] : .2 f } < span class = " sf-unit " > nm < / span > < / div >
< div class = " sf-band-range " > { supp_pct : .0 f } % of total < / div >
2026-01-29 20:46:58 +00:00
< / div >
< div class = " sf-band-card sf-ratio " >
2026-01-29 22:15:42 +00:00
< div class = " sf-band-label " > High Frequency < / div >
< div class = " sf-band-range " > & gt ; 20 cyc / apt < / div >
< div class = " sf-band-value " > { m [ ' hf_rms ' ] : .2 f } < span class = " sf-unit " > nm < / span > < / div >
< div class = " sf-band-range " > { hf_pct : .0 f } % of total < / div >
2026-01-29 20:46:58 +00:00
< / div >
< / div >
< / div >
"""
2026-01-29 18:28:10 +00:00
# ============================================================================
# HTML Report Generation
# ============================================================================
2026-01-29 20:20:01 +00:00
def _metric_color ( value , target ) :
""" Return a CSS color class based on value vs target. """
2026-01-29 18:28:10 +00:00
if value < = target :
2026-01-29 20:20:01 +00:00
return ' color: #16a34a; font-weight: 700; ' # green
2026-01-29 18:28:10 +00:00
ratio = value / target
if ratio < 1.5 :
2026-01-29 20:20:01 +00:00
return ' color: #d97706; font-weight: 700; ' # amber
return ' color: #dc2626; font-weight: 700; ' # red
2026-01-29 18:28:10 +00:00
2026-02-08 19:27:56 -05:00
def make_surface_plot ( X , Y , W_res , mask , inner_radius = None , title = " " , amp = 0.5 , downsample = PLOT_DOWNSAMPLE , include_plotlyjs = False ) :
2026-01-29 18:28:10 +00:00
""" 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 ] + 1 j * 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 ,
2026-01-29 20:20:01 +00:00
colorbar = dict ( title = dict ( text = " nm " , side = " right " , font = dict ( color = ' #1e293b ' ) ) , thickness = 12 , len = 0.5 , tickformat = " .1f " ,
tickfont = dict ( color = ' #1e293b ' ) ) ,
2026-01-29 18:28:10 +00:00
hovertemplate = " X: % {x:.1f} <br>Y: % {y:.1f} <br>Residual: % {z:.2f} nm<extra></extra> "
) )
# 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 ) ,
2026-01-29 20:20:01 +00:00
mode = ' lines ' , line = dict ( color = ' #64748b ' , width = 2 ) ,
2026-01-29 18:28:10 +00:00
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 ) ) ,
2026-01-29 20:20:01 +00:00
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 ' ) ) ,
2026-01-29 18:28:10 +00:00
aspectmode = ' manual ' ,
aspectratio = dict ( x = 1 , y = 1 , z = 0.4 ) ,
) ,
margin = dict ( t = 30 , b = 10 , l = 10 , r = 10 ) ,
2026-01-29 20:20:01 +00:00
* * _PLOTLY_LIGHT_LAYOUT ,
2026-01-29 20:46:58 +00:00
height = 650 ,
width = 1200 ,
2026-01-29 18:28:10 +00:00
)
2026-02-08 19:27:56 -05:00
div_id = f " surface_ { title . replace ( ' ' , ' _ ' ) } "
config = get_plotly_config ( f " WFE_Surface_ { title . replace ( ' ' , ' _ ' ) } " )
return fig . to_html ( include_plotlyjs = include_plotlyjs , full_html = False , div_id = div_id , config = config )
2026-01-29 18:28:10 +00:00
2026-01-29 20:32:55 +00:00
def make_bar_chart ( coeffs , title = " Zernike Coefficients " , max_modes = 50 ) :
2026-01-29 18:28:10 +00:00
""" Create horizontal bar chart of Zernike coefficient magnitudes. """
n = min ( len ( coeffs ) , max_modes )
2026-01-29 20:20:01 +00:00
labels = [ str ( zernike_label ( j ) ) [ : 40 ] for j in range ( 1 , n + 1 ) ]
2026-01-29 18:28:10 +00:00
vals = np . abs ( coeffs [ : n ] )
2026-01-29 20:20:01 +00:00
text_vals = [ f " { v : .3f } " for v in vals ]
2026-01-29 18:28:10 +00:00
fig = go . Figure ( go . Bar (
x = vals , y = labels , orientation = ' h ' ,
2026-01-29 20:20:01 +00:00
marker_color = _BLUE_PALETTE [ 0 ] ,
text = text_vals ,
textposition = ' outside ' ,
textfont = dict ( size = 9 , color = ' #475569 ' ) ,
2026-01-29 18:28:10 +00:00
hovertemplate = " % {y} <br>|c| = % {x:.3f} nm<extra></extra> " ,
) )
fig . update_layout (
height = max ( 400 , n * 22 ) ,
2026-01-29 20:20:01 +00:00
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 ' ) ) ,
2026-01-29 18:28:10 +00:00
)
2026-02-08 19:27:56 -05:00
div_id = f " bar_ { title . replace ( ' ' , ' _ ' ) } "
config = get_plotly_config ( f " Zernike_Coefficients_ { title . replace ( ' ' , ' _ ' ) } " )
return fig . to_html ( include_plotlyjs = False , full_html = False , div_id = div_id , config = config )
2026-01-29 18:28:10 +00:00
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
2026-01-29 20:20:01 +00:00
color = _MODE_COLORS [ color_idx % len ( _MODE_COLORS ) ]
dash = _MODE_DASHES [ color_idx % len ( _MODE_DASHES ) ]
2026-01-29 18:28:10 +00:00
# RSS of modes in this group at each angle
rss = np . sqrt ( np . sum ( coefficients_relative [ : , indices ] * * 2 , axis = 1 ) )
2026-01-29 20:20:01 +00:00
display_name = MODE_NAMES . get ( group_name , group_name )
2026-01-29 18:28:10 +00:00
2026-01-29 20:20:01 +00:00
# Group RSS trace (thick)
2026-01-29 18:28:10 +00:00
fig . add_trace ( go . Scatter (
x = angles , y = rss ,
mode = ' lines+markers ' ,
2026-01-29 20:20:01 +00:00
name = f " { display_name } (RSS) " ,
line = dict ( color = color , width = 3 , dash = dash ) ,
marker = dict ( size = 10 , symbol = ' circle ' ) ,
hovertemplate = f " { display_name } RSS<br>% {{ x:.1f }} °: % {{ y:.3f }} nm<extra></extra> " ,
legendgroup = group_name ,
2026-01-29 18:28:10 +00:00
) )
2026-01-29 20:20:01 +00:00
# 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 } <br>% {{ x:.1f }} °: % {{ y:.3f }} nm<extra></extra> " ,
legendgroup = group_name ,
showlegend = True ,
) )
2026-01-29 18:28:10 +00:00
color_idx + = 1
2026-01-29 20:20:01 +00:00
# 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 ' ) )
2026-01-29 18:28:10 +00:00
fig . update_layout (
2026-01-30 00:06:10 +00:00
height = 500 ,
width = 1100 ,
2026-01-29 20:20:01 +00:00
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 ' ) ) ,
2026-01-29 18:28:10 +00:00
)
2026-02-08 19:27:56 -05:00
config = get_plotly_config ( " Zernike_Trajectory " )
return fig . to_html ( include_plotlyjs = False , full_html = False , div_id = " trajectory_plot " , config = config )
2026-01-29 18:28:10 +00:00
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 ]
2026-01-29 20:20:01 +00:00
labels = [ str ( MODE_NAMES . get ( m , m ) ) for m in modes ]
2026-01-29 18:28:10 +00:00
fig = go . Figure ( )
fig . add_trace ( go . Bar (
y = labels , x = axial , orientation = ' h ' ,
2026-01-29 20:20:01 +00:00
name = ' Axial (sin \u03b8 ) ' , marker_color = ' #2563eb ' ,
text = [ f " { v : .3f } " for v in axial ] , textposition = ' outside ' ,
textfont = dict ( size = 9 , color = ' #475569 ' ) ,
2026-01-29 18:28:10 +00:00
hovertemplate = " % {y} <br>Axial: % {x:.3f} nm/unit<extra></extra> "
) )
fig . add_trace ( go . Bar (
y = labels , x = lateral , orientation = ' h ' ,
2026-01-29 20:20:01 +00:00
name = ' Lateral (cos \u03b8 ) ' , marker_color = ' #7c3aed ' ,
text = [ f " { v : .3f } " for v in lateral ] , textposition = ' outside ' ,
textfont = dict ( size = 9 , color = ' #475569 ' ) ,
2026-01-29 18:28:10 +00:00
hovertemplate = " % {y} <br>Lateral: % {x:.3f} nm/unit<extra></extra> "
) )
fig . update_layout (
barmode = ' group ' ,
2026-01-29 20:20:01 +00:00
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 ' ) ) ,
2026-01-29 18:28:10 +00:00
)
2026-02-08 19:27:56 -05:00
config = get_plotly_config ( " Sensitivity_Axial_vs_Lateral " )
return fig . to_html ( include_plotlyjs = False , full_html = False , div_id = " sensitivity_bar " , config = config )
2026-01-29 18:28:10 +00:00
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 ]
2026-01-29 20:20:01 +00:00
labels = [ f " { a } \u00b0 vs { ref_angle } \u00b0 " for a in angles ]
2026-01-29 18:28:10 +00:00
fig = go . Figure ( go . Bar (
x = labels , y = rms_vals ,
2026-01-29 20:20:01 +00:00
marker_color = _BLUE_PALETTE [ 0 ] ,
2026-01-29 18:28:10 +00:00
text = [ f " { v : .2f } nm " for v in rms_vals ] ,
textposition = ' outside ' ,
2026-01-29 20:20:01 +00:00
textfont = dict ( size = 11 , color = ' #1e293b ' ) ,
2026-01-29 18:28:10 +00:00
hovertemplate = " % {x} : % {y:.2f} nm<extra></extra> "
) )
fig . update_layout (
height = 350 ,
margin = dict ( t = 30 , b = 40 , l = 60 , r = 20 ) ,
2026-01-29 20:20:01 +00:00
* * _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 ) ) ,
2026-01-29 18:28:10 +00:00
)
2026-02-08 19:27:56 -05:00
config = get_plotly_config ( " Per_Angle_RMS_WFE " )
return fig . to_html ( include_plotlyjs = False , full_html = False , div_id = " per_angle_rms " , config = config )
2026-01-29 18:28:10 +00:00
# ============================================================================
# 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 " \n OP2 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 ( " \n Generating HTML report... " )
# Surface plots
2026-02-08 19:27:56 -05:00
# First plot embeds the full Plotly library (~3.5MB) for offline viewing
2026-01-29 18:28:10 +00:00
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 ' ] ,
2026-02-08 19:27:56 -05:00
inner_radius = inner_radius , title = " 40 vs 20 " , include_plotlyjs = True
2026-01-29 18:28:10 +00:00
)
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 " )
2026-01-29 22:15:42 +00:00
# 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 = { }
2026-01-29 23:49:03 +00:00
# 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 ) ,
2026-01-29 22:15:42 +00:00
] :
r = angle_results [ ang_key ]
2026-01-29 23:49:03 +00:00
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 ]
2026-01-29 22:15:42 +00:00
try :
2026-01-29 23:49:03 +00:00
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 " )
2026-01-29 22:15:42 +00:00
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 " "
2026-01-29 23:58:14 +00:00
psd_summary_40 = _psd_summary_html ( psd_bands [ ' 40° vs 20° ' ] , ' 40° vs 20° (Relative) ' ) if ' 40° vs 20° ' in psd_bands else " "
psd_summary_60 = _psd_summary_html ( psd_bands [ ' 60° vs 20° ' ] , ' 60° vs 20° (Relative) ' ) if ' 60° vs 20° ' in psd_bands else " "
psd_summary_90 = _psd_summary_html ( psd_bands [ ' 90° (Abs) ' ] , ' 90° Manufacturing (Absolute) ' ) if ' 90° (Abs) ' in psd_bands else " "
2026-01-29 20:46:58 +00:00
2026-01-29 18:28:10 +00:00
# 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 ( ) ) :
2026-01-29 20:20:01 +00:00
unit = " \u00b0 " if " angle " in k else " mm "
2026-01-29 18:28:10 +00:00
rows + = f " <tr><td> { k } </td><td> { v : .4f } { unit } </td></tr> \n "
params_html = f """
< div class = " section " >
2026-01-29 20:20:01 +00:00
< h2 > 7. Design Parameters ( Trial #{study_params.get('trial_id', '?')})</h2>
2026-01-29 18:28:10 +00:00
< table class = " data-table " > < thead > < tr > < th > Parameter < / th > < th > Value < / th > < / tr > < / thead >
< tbody > { rows } < / tbody > < / table >
< / div >
"""
# 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 """ <tr>
2026-01-29 20:20:01 +00:00
< td > < b > { ang } \u00b0 < / b > < / td >
2026-01-29 18:28:10 +00:00
< td > { abs_glob : .2 f } < / td > < td > { abs_filt : .2 f } < / td >
< td > { rel_filt : .2 f } < / td >
< td > { ab [ ' astigmatism ' ] : .2 f } < / td > < td > { ab [ ' coma ' ] : .2 f } < / td >
< td > { ab [ ' trefoil ' ] : .2 f } < / td > < td > { ab [ ' spherical ' ] : .2 f } < / td >
< / tr > """
# Trajectory metrics table
traj_metrics_html = " "
if traj_result :
traj_metrics_html = f """
< div class = " metrics-grid " >
< div class = " metric-card " >
< div class = " metric-label " > Coma RMS < / div >
< div class = " metric-value " > { traj_result [ ' coma_rms_nm ' ] : .2 f } nm < / div >
< / div >
< div class = " metric-card " >
< div class = " metric-label " > Astigmatism RMS < / div >
< div class = " metric-value " > { traj_result [ ' astigmatism_rms_nm ' ] : .2 f } nm < / div >
< / div >
< div class = " metric-card " >
< div class = " metric-label " > Trefoil RMS < / div >
< div class = " metric-value " > { traj_result [ ' trefoil_rms_nm ' ] : .2 f } nm < / div >
< / div >
< div class = " metric-card " >
< div class = " metric-label " > Spherical RMS < / div >
< div class = " metric-value " > { traj_result [ ' spherical_rms_nm ' ] : .2 f } nm < / div >
< / div >
< div class = " metric-card " >
< div class = " metric-label " > Total Filtered RMS < / div >
< div class = " metric-value " > { traj_result [ ' total_filtered_rms_nm ' ] : .2 f } nm < / div >
< / div >
< div class = " metric-card " >
2026-01-29 20:20:01 +00:00
< div class = " metric-label " > Linear Fit R \u00b2 < / div >
2026-01-29 18:28:10 +00:00
< div class = " metric-value " > { traj_result [ ' linear_fit_r2 ' ] : .4 f } < / div >
< / div >
< / div >
< p class = " note " > Dominant aberration mode : < b > { MODE_NAMES . get ( traj_result [ ' dominant_mode ' ] , traj_result [ ' dominant_mode ' ] ) } < / b > < / p >
2026-01-29 20:20:01 +00:00
< p class = " note " > Mode ranking : { ' \u2192 ' . join ( traj_result [ ' mode_ranking ' ] [ : 5 ] ) } < / p >
2026-01-29 18:28:10 +00:00
"""
# Manufacturing details
mfg_html = f """
< table class = " data-table " >
2026-01-29 20:20:01 +00:00
< thead > < tr > < th > Metric < / th > < th > Absolute 90 \u00b0 < / th > < th > Correction ( 90 \u00b0 \u221220 \u00b0 ) < / th > < / tr > < / thead >
2026-01-29 18:28:10 +00:00
< tbody >
< tr > < td > Defocus ( J4 ) < / td > < td > { mfg_abs_aberr [ ' defocus ' ] : .2 f } nm < / td > < td > { mfg_correction [ ' defocus ' ] : .2 f } nm < / td > < / tr >
< tr > < td > Astigmatism ( J5 + J6 ) < / td > < td > { mfg_abs_aberr [ ' astigmatism ' ] : .2 f } nm < / td > < td > { mfg_correction [ ' astigmatism ' ] : .2 f } nm < / td > < / tr >
< tr > < td > Coma ( J7 + J8 ) < / td > < td > { mfg_abs_aberr [ ' coma ' ] : .2 f } nm < / td > < td > { mfg_correction [ ' coma ' ] : .2 f } nm < / td > < / tr >
< tr > < td > Trefoil ( J9 + J10 ) < / td > < td > { mfg_abs_aberr [ ' trefoil ' ] : .2 f } nm < / td > < td > { mfg_correction [ ' trefoil ' ] : .2 f } nm < / td > < / tr >
< tr > < td > Spherical ( J11 ) < / td > < td > { mfg_abs_aberr [ ' spherical ' ] : .2 f } nm < / td > < td > { mfg_correction [ ' spherical ' ] : .2 f } nm < / td > < / tr >
2026-01-29 20:20:01 +00:00
< tr class = " highlight " > < td > < b > J1 \u2212J3 Filtered RMS < / b > < / td > < td > { r90 [ ' rms_abs ' ] [ ' rms_j1to3 ' ] : .2 f } nm < / td > < td > < b > { mfg_rms_j1to3 : .2 f } nm < / b > < / td > < / tr >
2026-01-29 18:28:10 +00:00
< / tbody >
< / table >
"""
2026-01-29 20:20:01 +00:00
# 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
2026-01-29 22:15:42 +00:00
sec_psd = 5 if traj_result else 4
sec_mfg = sec_psd + 1
2026-01-29 20:20:01 +00:00
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
2026-01-29 18:28:10 +00:00
# Assemble full HTML
html = f """ <!DOCTYPE html>
< html lang = " en " >
< head >
< meta charset = " UTF-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1.0 " >
< title > { title } < / title >
2026-02-08 19:27:56 -05:00
< ! - - Plotly . js is embedded inline in the first surface plot for offline viewing - - >
2026-01-29 18:28:10 +00:00
< style >
: root { {
2026-01-29 20:20:01 +00:00
- - bg - primary : #ffffff;
- - bg - secondary : #f8fafc;
- - bg - card : #ffffff;
- - text - primary : #1e293b;
- - text - secondary : #64748b;
- - accent : #2563eb;
- - accent - light : #dbeafe;
- - success : #16a34a;
- - warning : #d97706;
- - danger : #dc2626;
- - border : #e2e8f0;
- - border - strong : #cbd5e1;
2026-01-29 18:28:10 +00:00
} }
* { { margin : 0 ; padding : 0 ; box - sizing : border - box ; } }
body { {
font - family : ' Inter ' , - apple - system , BlinkMacSystemFont , ' Segoe UI ' , system - ui , sans - serif ;
2026-01-29 20:20:01 +00:00
background : var ( - - bg - secondary ) ;
2026-01-29 18:28:10 +00:00
color : var ( - - text - primary ) ;
line - height : 1.6 ;
} }
. container { { max - width : 1400 px ; margin : 0 auto ; padding : 2 rem ; } }
/ * Header * /
. header { {
2026-01-29 20:20:01 +00:00
background : var ( - - bg - card ) ;
2026-01-29 18:28:10 +00:00
border : 1 px solid var ( - - border ) ;
2026-01-29 20:20:01 +00:00
border - radius : 8 px ;
2026-01-29 18:28:10 +00:00
padding : 2 rem 3 rem ;
margin - bottom : 2 rem ;
display : flex ;
justify - content : space - between ;
align - items : center ;
} }
2026-01-29 20:20:01 +00:00
. header h1 { { font - size : 1.6 rem ; font - weight : 700 ; color : var ( - - text - primary ) ; } }
. header . subtitle { { color : var ( - - text - secondary ) ; font - size : 0.9 rem ; margin - top : 0.3 rem ; } }
2026-01-29 18:28:10 +00:00
. header . branding { {
text - align : right ;
font - size : 0.85 rem ;
color : var ( - - text - secondary ) ;
} }
2026-01-29 20:20:01 +00:00
. header . branding . by - line { { color : #94a3b8; font-size: 0.8rem; margin-top: 0.2rem; }}
. header . branding . tagline { { color : var ( - - text - secondary ) ; font - size : 0.8 rem ; margin - top : 0.15 rem ; } }
2026-01-29 18:28:10 +00:00
/ * Sections * /
. section { {
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
2026-01-29 20:20:01 +00:00
border - radius : 8 px ;
2026-01-29 18:28:10 +00:00
padding : 1.5 rem 2 rem ;
margin - bottom : 1.5 rem ;
} }
. section h2 { {
2026-01-29 20:20:01 +00:00
font - size : 1.2 rem ;
font - weight : 600 ;
color : var ( - - text - primary ) ;
2026-01-29 18:28:10 +00:00
margin - bottom : 1 rem ;
padding - bottom : 0.5 rem ;
border - bottom : 1 px solid var ( - - border ) ;
} }
/ * Executive Summary * /
. exec - grid { {
display : grid ;
2026-01-29 20:20:01 +00:00
grid - template - columns : repeat ( auto - fit , minmax ( 280 px , 1 fr ) ) ;
2026-01-29 18:28:10 +00:00
gap : 1 rem ;
margin : 1 rem 0 ;
} }
. exec - card { {
2026-01-29 20:20:01 +00:00
background : var ( - - bg - secondary ) ;
2026-01-29 18:28:10 +00:00
border : 1 px solid var ( - - border ) ;
2026-01-29 20:20:01 +00:00
border - radius : 6 px ;
2026-01-29 18:28:10 +00:00
padding : 1.2 rem ;
} }
. exec - card . label { { font - size : 0.85 rem ; color : var ( - - text - secondary ) ; margin - bottom : 0.3 rem ; } }
. exec - card . value { { font - size : 1.8 rem ; font - weight : 700 ; } }
2026-01-29 20:20:01 +00:00
. exec - card . unit { { font - size : 0.9 rem ; font - weight : 400 ; color : var ( - - text - secondary ) ; } }
. exec - footnote { {
margin - top : 1 rem ;
padding : 0.75 rem 1 rem ;
background : var ( - - bg - secondary ) ;
border - left : 3 px solid var ( - - accent ) ;
border - radius : 0 4 px 4 px 0 ;
font - size : 0.82 rem ;
color : var ( - - text - secondary ) ;
2026-01-29 18:28:10 +00:00
} }
/ * Tables * /
. data - table { {
width : 100 % ;
border - collapse : collapse ;
margin : 0.5 rem 0 ;
} }
. data - table th , . data - table td { {
padding : 0.6 rem 1 rem ;
text - align : left ;
border - bottom : 1 px solid var ( - - border ) ;
} }
. data - table th { {
2026-01-29 20:20:01 +00:00
background : var ( - - bg - secondary ) ;
2026-01-29 18:28:10 +00:00
font - weight : 600 ;
2026-01-29 20:20:01 +00:00
font - size : 0.82 rem ;
2026-01-29 18:28:10 +00:00
text - transform : uppercase ;
letter - spacing : 0.05 em ;
color : var ( - - text - secondary ) ;
} }
2026-01-29 20:20:01 +00:00
. data - table tr : hover { { background : #f1f5f9; }}
2026-01-29 18:28:10 +00:00
. data - table tr . highlight td { {
2026-01-29 20:20:01 +00:00
background : var ( - - accent - light ) ;
2026-01-29 18:28:10 +00:00
font - weight : 600 ;
} }
/ * Metrics Grid * /
. metrics - grid { {
display : grid ;
2026-01-29 20:20:01 +00:00
grid - template - columns : repeat ( auto - fit , minmax ( 170 px , 1 fr ) ) ;
2026-01-29 18:28:10 +00:00
gap : 0.8 rem ;
margin : 1 rem 0 ;
} }
. metric - card { {
2026-01-29 20:20:01 +00:00
background : var ( - - bg - secondary ) ;
2026-01-29 18:28:10 +00:00
border : 1 px solid var ( - - border ) ;
2026-01-29 20:20:01 +00:00
border - radius : 6 px ;
2026-01-29 18:28:10 +00:00
padding : 1 rem ;
text - align : center ;
} }
. metric - label { { font - size : 0.8 rem ; color : var ( - - text - secondary ) ; margin - bottom : 0.3 rem ; } }
2026-01-29 20:20:01 +00:00
. metric - value { { font - size : 1.2 rem ; font - weight : 700 ; color : var ( - - accent ) ; } }
2026-01-29 18:28:10 +00:00
/ * Plots * /
. plot - grid { {
display : grid ;
2026-01-29 20:46:58 +00:00
grid - template - columns : repeat ( auto - fit , minmax ( 1100 px , 1 fr ) ) ;
2026-01-29 18:28:10 +00:00
gap : 1 rem ;
} }
. plot - container { {
2026-01-29 20:20:01 +00:00
background : var ( - - bg - card ) ;
2026-01-29 18:28:10 +00:00
border : 1 px solid var ( - - border ) ;
2026-01-29 20:20:01 +00:00
border - radius : 6 px ;
2026-01-29 18:28:10 +00:00
padding : 1 rem ;
2026-01-29 20:20:01 +00:00
min - height : 400 px ;
2026-01-29 18:28:10 +00:00
} }
. plot - container h3 { {
2026-01-29 20:20:01 +00:00
font - size : 0.95 rem ;
font - weight : 600 ;
2026-01-29 18:28:10 +00:00
margin - bottom : 0.5 rem ;
color : var ( - - text - secondary ) ;
} }
2026-01-29 20:46:58 +00:00
/ * Spatial Frequency Breakdown * /
. sf - breakdown { {
margin : 1 rem 0 1.5 rem 0 ;
padding : 1.2 rem ;
background : var ( - - bg - secondary ) ;
border : 1 px solid var ( - - border ) ;
border - radius : 8 px ;
} }
. sf - breakdown h4 { {
font - size : 0.9 rem ;
font - weight : 600 ;
color : var ( - - text - secondary ) ;
text - transform : uppercase ;
letter - spacing : 0.05 em ;
margin - bottom : 0.8 rem ;
} }
. sf - band - grid { {
display : grid ;
grid - template - columns : 1 fr 1 fr auto ;
gap : 0.8 rem ;
margin - bottom : 1 rem ;
} }
. sf - band - card { {
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border - radius : 6 px ;
padding : 0.8 rem 1 rem ;
text - align : center ;
} }
. sf - band - label { {
font - size : 0.82 rem ;
font - weight : 600 ;
color : var ( - - text - primary ) ;
} }
. sf - band - range { {
font - size : 0.75 rem ;
color : var ( - - text - secondary ) ;
margin : 0.15 rem 0 0.4 rem 0 ;
} }
. sf - band - value { {
font - size : 1.3 rem ;
font - weight : 700 ;
color : var ( - - accent ) ;
} }
. sf - unit { {
font - size : 0.8 rem ;
font - weight : 400 ;
color : var ( - - text - secondary ) ;
} }
. sf - lsf { { border - top : 3 px solid #2563eb; }}
. sf - msf { { border - top : 3 px solid #7c3aed; }}
. sf - ratio { { border - top : 3 px solid #16a34a; min-width: 100px; }}
. sf - order - grid { {
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border - radius : 6 px ;
padding : 0.8 rem 1 rem ;
} }
. sf - order - title { {
font - size : 0.8 rem ;
font - weight : 600 ;
color : var ( - - text - secondary ) ;
margin - bottom : 0.5 rem ;
} }
. sf - order - items { {
display : flex ;
flex - wrap : wrap ;
gap : 0.3 rem 1.2 rem ;
} }
. sf - order - item { {
display : flex ;
gap : 0.3 rem ;
font - size : 0.85 rem ;
} }
. sf - order - n { {
color : var ( - - text - secondary ) ;
font - weight : 500 ;
} }
. sf - order - val { {
color : var ( - - text - primary ) ;
font - weight : 600 ;
} }
2026-01-29 18:28:10 +00:00
/ * Collapsible * /
details { { margin : 0.5 rem 0 ; } }
summary { {
cursor : pointer ;
font - weight : 600 ;
2026-01-29 20:20:01 +00:00
padding : 0.6 rem 0.8 rem ;
background : var ( - - bg - secondary ) ;
2026-01-29 18:28:10 +00:00
border - radius : 6 px ;
border : 1 px solid var ( - - border ) ;
2026-01-29 20:20:01 +00:00
color : var ( - - text - primary ) ;
font - size : 0.95 rem ;
2026-01-29 18:28:10 +00:00
} }
2026-01-29 20:20:01 +00:00
summary : hover { { background : var ( - - accent - light ) ; } }
2026-01-29 18:28:10 +00:00
details > div { { padding : 1 rem ; } }
2026-01-29 20:20:01 +00:00
. note { { color : var ( - - text - secondary ) ; font - size : 0.88 rem ; margin : 0.5 rem 0 ; } }
. two - col { { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 1.5 rem ; } }
@media ( max - width : 900 px ) { { . two - col { { grid - template - columns : 1 fr ; } } } }
2026-01-29 18:28:10 +00:00
/ * Print styles * /
@media print { {
2026-01-29 20:20:01 +00:00
body { { background : white ; color : black ; font - size : 10 pt ; } }
. container { { max - width : 100 % ; padding : 0.5 cm ; } }
. header { { border : 1 px solid #999; }}
. section { { border : 1 px solid #ccc; page-break-inside: avoid; margin-bottom: 0.5cm; }}
. plot - container { { page - break - inside : avoid ; } }
details { { display : block ; } }
details > summary { { display : none ; } }
details > div { { padding : 0 ; } }
. exec - grid { { grid - template - columns : repeat ( 2 , 1 fr ) ; } }
. no - print { { display : none ; } }
2026-01-29 18:28:10 +00:00
} }
2026-01-29 20:20:01 +00:00
/ * Footer * /
. footer { {
text - align : center ;
padding : 2 rem ;
color : var ( - - text - secondary ) ;
font - size : 0.8 rem ;
border - top : 1 px solid var ( - - border ) ;
margin - top : 1 rem ;
} }
2026-01-29 18:28:10 +00:00
< / style >
< / head >
< body >
< div class = " container " >
< ! - - Header - - >
< div class = " header " >
< div >
2026-01-29 20:20:01 +00:00
< h1 > { title } < / h1 >
2026-01-29 18:28:10 +00:00
< div class = " subtitle " > Generated { timestamp } & nbsp ; | & nbsp ; OP2 : { op2_path . name } < / div >
{ ' <div class= " subtitle " >Study: ' + study_name + ' </div> ' if study_name else ' ' }
< / div >
< div class = " branding " >
2026-01-29 20:20:01 +00:00
< svg width = " 120 " height = " 32 " viewBox = " 0 0 120 32 " xmlns = " http://www.w3.org/2000/svg " >
< text x = " 0 " y = " 24 " font - family = " Inter, system-ui, sans-serif " font - size = " 22 " font - weight = " 700 " fill = " #2563eb " > ATOMIZER < / text >
< / svg >
< div class = " by-line " > by Atomaste < / div >
< div class = " tagline " > FEA Optimization Platform < / div >
2026-01-29 18:28:10 +00:00
< / div >
< / div >
2026-01-29 20:20:01 +00:00
< ! - - 1. Executive Summary - - >
2026-01-29 18:28:10 +00:00
< div class = " section " >
2026-01-29 20:20:01 +00:00
< h2 > 1. Executive Summary < / h2 >
2026-01-29 18:28:10 +00:00
< div class = " exec-grid " >
< div class = " exec-card " >
2026-01-29 20:20:01 +00:00
< div class = " label " > WFE 40 \u00b0 vs 20 \u00b0 ( Tracking ) < / div >
< div class = " value " style = " {style_40} " > { wfe_40_20 : .2 f } < span class = " unit " > nm < / span > < / div >
2026-01-29 18:28:10 +00:00
< / div >
< div class = " exec-card " >
2026-01-29 20:20:01 +00:00
< div class = " label " > WFE 60 \u00b0 vs 20 \u00b0 ( Tracking ) < / div >
< div class = " value " style = " {style_60} " > { wfe_60_20 : .2 f } < span class = " unit " > nm < / span > < / div >
2026-01-29 18:28:10 +00:00
< / div >
< div class = " exec-card " >
2026-01-29 20:20:01 +00:00
< div class = " label " > MFG 90 \u00b0 ( J1 \u2212J3 Filtered ) < / div >
< div class = " value " style = " {style_mfg} " > { mfg_90 : .2 f } < span class = " unit " > nm < / span > < / div >
2026-01-29 18:28:10 +00:00
< / div >
< div class = " exec-card " >
2026-01-29 20:20:01 +00:00
< div class = " label " > Weighted Sum ( 6 \u00b7W40 + 5 \u00b7W60 + 3 \u00b7MFG ) < / div >
< div class = " value " style = " color: var(--accent); font-weight: 700; " > { ws : .1 f } < / div >
2026-01-29 18:28:10 +00:00
< / div >
< / div >
2026-01-29 20:20:01 +00:00
< div class = " exec-footnote " >
Design targets & mdash ;
WFE 40 \u00b0 \u221220 \u00b0 \u2264 { targets [ ' wfe_40_20 ' ] : .1 f } nm & nbsp ; | & nbsp ;
WFE 60 \u00b0 \u221220 \u00b0 \u2264 { targets [ ' wfe_60_20 ' ] : .1 f } nm & nbsp ; | & nbsp ;
MFG 90 \u00b0 \u2264 { targets [ ' mfg_90 ' ] : .1 f } nm & nbsp ; | & nbsp ;
Weighted sum : lower is better .
{ ' <br>Annular aperture: inner radius = ' + f ' { inner_radius : .1f } mm ( \u00f8 { 2 * inner_radius : .1f } mm central hole) ' if inner_radius else ' ' }
< / div >
2026-01-29 18:28:10 +00:00
< / div >
2026-01-29 20:20:01 +00:00
< ! - - 2. Per - Angle Summary - - >
2026-01-29 18:28:10 +00:00
< div class = " section " >
2026-01-29 20:20:01 +00:00
< h2 > 2. Per - Angle RMS Summary < / h2 >
2026-01-29 18:28:10 +00:00
{ per_angle_plot }
< table class = " data-table " style = " margin-top:1rem " >
< thead >
< tr >
< th > Angle < / th > < th > Abs Global RMS < / th > < th > Abs Filtered RMS < / th >
< th > Rel Filtered RMS < / th >
< th > Astigmatism < / th > < th > Coma < / th > < th > Trefoil < / th > < th > Spherical < / th >
< / tr >
< / thead >
< tbody > { angle_detail_rows } < / tbody >
< / table >
2026-01-29 20:20:01 +00:00
< p class = " note " > All values in nm . Filtered = J1 \u2212J4 removed . Relative = vs 20 \u00b0 reference . Aberrations are absolute . < / p >
2026-01-29 18:28:10 +00:00
< / div >
2026-01-29 20:20:01 +00:00
< ! - - 3. Surface Plots - - >
2026-01-29 18:28:10 +00:00
< div class = " section " >
2026-01-29 20:20:01 +00:00
< h2 > { sec_surface } . Wavefront Error Surface Maps < / h2 >
< p class = " note " > 3 D residual surfaces after removing piston , tip , tilt , and defocus ( J1 \u2212J4 ) . Interactive \u2014 drag to rotate . < / p >
2026-01-29 18:28:10 +00:00
< div class = " plot-grid " >
< div class = " plot-container " >
2026-01-29 20:20:01 +00:00
< h3 > 40 \u00b0 vs 20 \u00b0 ( Relative ) < / h3 >
2026-01-29 18:28:10 +00:00
{ surf_40 }
< / div >
< div class = " plot-container " >
2026-01-29 20:20:01 +00:00
< h3 > 60 \u00b0 vs 20 \u00b0 ( Relative ) < / h3 >
2026-01-29 18:28:10 +00:00
{ surf_60 }
< / div >
< / div >
< div class = " plot-container " style = " margin-top:1rem " >
2026-01-29 20:20:01 +00:00
< h3 > 90 \u00b0 Manufacturing ( Absolute ) < / h3 >
2026-01-29 18:28:10 +00:00
{ surf_90 }
< / div >
< / div >
< ! - - Trajectory Analysis - - >
2026-01-29 20:20:01 +00:00
{ ' <div class= " section " ><h2> ' + str ( sec_traj ) + ' . Zernike Trajectory Analysis</h2> ' +
2026-01-29 18:28:10 +00:00
' <p class= " note " >Mode-specific integrated RMS across the operating elevation range. ' +
2026-01-29 20:20:01 +00:00
' The linear model c<sub>j</sub>( \u03b8 ) = a<sub>j</sub> \u00b7 \u0394 sin \u03b8 + b<sub>j</sub> \u00b7 \u0394 cos \u03b8 decomposes gravity into axial and lateral components.</p> ' +
2026-01-29 18:28:10 +00:00
traj_metrics_html +
2026-01-30 00:06:10 +00:00
' <div class= " plot-container " style= " margin-top:1rem " ><h3>Mode RMS vs Elevation Angle</h3> ' + traj_plot_html + ' </div> ' +
' <div class= " plot-container " style= " margin-top:1rem " ><h3>Axial vs Lateral Sensitivity</h3> ' + sens_plot_html + ' </div> ' +
' </div> ' if traj_result else ' ' }
2026-01-29 18:28:10 +00:00
2026-01-29 22:15:42 +00:00
< ! - - PSD Analysis - - >
{ ' <div class= " section " ><h2> ' + str ( sec_psd ) + ' . Power Spectral Density Analysis</h2> ' +
' <p class= " note " >Surface PSD computed via 2D FFT with Hann windowing and radial averaging ' +
' (Tony Hull / JWST methodology). Frequency bands: Gravity signature (0.1 \u2013 2 cyc/apt), ' +
' Support print-through (2 \u2013 20 cyc/apt), High frequency (>20 cyc/apt).</p> ' +
' <div class= " plot-container " style= " margin:1rem 0 " ><h3>PSD \u2014 Log-Log</h3> ' + psd_plot_html + ' </div> ' +
psd_summary_40 + psd_summary_60 + psd_summary_90 +
' </div> ' if psd_plot_html else ' ' }
2026-01-29 18:28:10 +00:00
< ! - - Manufacturing Analysis - - >
< div class = " section " >
2026-01-29 20:20:01 +00:00
< h2 > { sec_mfg } . Manufacturing Analysis ( 90 \u00b0 Orientation ) < / h2 >
2026-01-29 18:28:10 +00:00
< p class = " note " >
2026-01-29 20:20:01 +00:00
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 .
2026-01-29 18:28:10 +00:00
< / p >
{ mfg_html }
< / div >
< ! - - Design Parameters - - >
{ params_html }
< ! - - Zernike Coefficient Details - - >
< div class = " section " >
2026-01-29 20:20:01 +00:00
< h2 > { sec_zernike } . Zernike Coefficient Details < / h2 >
2026-01-29 18:28:10 +00:00
< details >
2026-01-29 20:20:01 +00:00
< summary > 40 \u00b0 vs 20 \u00b0 \u2014 Relative Coefficients < / summary >
2026-01-29 22:15:42 +00:00
< div > { bar_40 } < / div >
2026-01-29 18:28:10 +00:00
< / details >
< details >
2026-01-29 20:20:01 +00:00
< summary > 60 \u00b0 vs 20 \u00b0 \u2014 Relative Coefficients < / summary >
2026-01-29 22:15:42 +00:00
< div > { bar_60 } < / div >
2026-01-29 18:28:10 +00:00
< / details >
< details >
2026-01-29 20:20:01 +00:00
< summary > 90 \u00b0 \u2014 Absolute Coefficients < / summary >
2026-01-29 22:15:42 +00:00
< div > { bar_90 } < / div >
2026-01-29 18:28:10 +00:00
< / details >
< / div >
< ! - - Methodology - - >
2026-01-29 20:20:01 +00:00
< div class = " section " >
< h2 > { sec_method } . Methodology < / h2 >
2026-01-29 18:28:10 +00:00
< table class = " data-table " >
< tbody >
< tr > < td > < b > Zernike Modes < / b > < / td > < td > { N_MODES } ( Noll convention ) < / td > < / tr >
2026-01-29 20:20:01 +00:00
< tr > < td > < b > Filtered Modes < / b > < / td > < td > J1 \u2212J4 ( Piston , Tip , Tilt , Defocus ) < / td > < / tr >
< tr > < td > < b > WFE Calculation < / b > < / td > < td > WFE = 2 \u00d7 Surface Error ( reflective ) < / td > < / tr >
< tr > < td > < b > Displacement Unit < / b > < / td > < td > { DISP_UNIT } \u2192 nm ( { NM_SCALE : .0 e } \u00d7 ) < / td > < / tr >
2026-01-29 18:28:10 +00:00
< tr > < td > < b > Aperture < / b > < / td > < td > { ' Annular (inner R = ' + f ' { inner_radius : .1f } mm) ' if inner_radius else ' Full disk ' } < / td > < / tr >
2026-01-29 20:20:01 +00:00
< tr > < td > < b > Reference Angle < / b > < / td > < td > 20 \u00b0 ( polishing / measurement orientation ) < / td > < / tr >
< tr > < td > < b > MFG Objective < / b > < / td > < td > 90 \u00b0 \u221220 \u00b0 relative , J1 \u2212J3 filtered ( optician workload ) < / td > < / tr >
< tr > < td > < b > Weighted Sum < / b > < / td > < td > 6 \u00d7WFE ( 40 \u221220 ) + 5 \u00d7WFE ( 60 \u221220 ) + 3 \u00d7MFG ( 90 ) < / td > < / tr >
{ ' <tr><td><b>Trajectory R \u00b2 </b></td><td> ' + f ' { traj_result [ " linear_fit_r2 " ] : .6f } ' + ' </td></tr> ' if traj_result else ' ' }
2026-01-29 18:28:10 +00:00
< / tbody >
< / table >
< / div >
< ! - - Footer - - >
2026-01-29 20:20:01 +00:00
< div class = " footer " >
2026-01-29 18:28:10 +00:00
Generated by < b > Atomizer < / b > Optical Report Generator & nbsp ; | & nbsp ; { timestamp } < br >
2026-01-29 20:20:01 +00:00
\u00a9 Atomaste & nbsp ; | & nbsp ; atomaste . ca
2026-01-29 18:28:10 +00:00
< / div >
< / div >
< / body >
< / html > """
# 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 " \n Location: { 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 " \n ERROR: { e } " )
import traceback
traceback . print_exc ( )
sys . exit ( 1 )
if __name__ == ' __main__ ' :
main ( )