1527 lines
56 KiB
Plaintext
1527 lines
56 KiB
Plaintext
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Atomizer Optical Performance Report Generator
|
||
|
|
===============================================
|
||
|
|
|
||
|
|
Generates a comprehensive, CDR-ready HTML report for the optical
|
||
|
|
performance of an M1 mirror design from FEA results (OP2 file).
|
||
|
|
|
||
|
|
The report combines:
|
||
|
|
1. Executive Summary with pass/fail vs design targets
|
||
|
|
2. Per-Angle Wavefront Error Analysis (3D surface plots)
|
||
|
|
3. Zernike Trajectory Analysis (mode-specific metrics across elevation)
|
||
|
|
4. Sensitivity Matrix (axial vs lateral load response)
|
||
|
|
5. Manufacturing Analysis (90° correction metrics)
|
||
|
|
6. Full Zernike coefficient tables
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
conda activate atomizer
|
||
|
|
python generate_optical_report.py "path/to/solution.op2"
|
||
|
|
|
||
|
|
# With annular aperture
|
||
|
|
python generate_optical_report.py "path/to/solution.op2" --inner-radius 135.75
|
||
|
|
|
||
|
|
# Custom targets
|
||
|
|
python generate_optical_report.py "path/to/solution.op2" --target-40 4.0 --target-60 10.0 --target-mfg 20.0
|
||
|
|
|
||
|
|
# Include design parameters from study database
|
||
|
|
python generate_optical_report.py "path/to/solution.op2" --study-db "path/to/study.db" --trial 725
|
||
|
|
|
||
|
|
Output:
|
||
|
|
Creates a single comprehensive HTML file:
|
||
|
|
{basename}_OPTICAL_REPORT_{timestamp}.html
|
||
|
|
|
||
|
|
Author: Atomizer / Atomaste
|
||
|
|
Created: 2026-01-29
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
import os
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
from math import factorial
|
||
|
|
from datetime import datetime
|
||
|
|
import numpy as np
|
||
|
|
from numpy.linalg import LinAlgError
|
||
|
|
|
||
|
|
# Add Atomizer root to path
|
||
|
|
ATOMIZER_ROOT = Path(__file__).parent.parent
|
||
|
|
if str(ATOMIZER_ROOT) not in sys.path:
|
||
|
|
sys.path.insert(0, str(ATOMIZER_ROOT))
|
||
|
|
|
||
|
|
try:
|
||
|
|
import plotly.graph_objects as go
|
||
|
|
from plotly.subplots import make_subplots
|
||
|
|
from matplotlib.tri import Triangulation
|
||
|
|
from pyNastran.op2.op2 import OP2
|
||
|
|
from pyNastran.bdf.bdf import BDF
|
||
|
|
except ImportError as e:
|
||
|
|
print(f"ERROR: Missing dependency: {e}")
|
||
|
|
print("Run: conda activate atomizer")
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Import Atomizer extractors
|
||
|
|
from optimization_engine.extractors.extract_zernike import (
|
||
|
|
compute_zernike_coefficients,
|
||
|
|
compute_rms_metrics,
|
||
|
|
compute_aberration_magnitudes,
|
||
|
|
compute_rms_with_custom_filter,
|
||
|
|
zernike_noll,
|
||
|
|
zernike_label,
|
||
|
|
zernike_name,
|
||
|
|
noll_indices,
|
||
|
|
read_node_geometry,
|
||
|
|
find_geometry_file,
|
||
|
|
extract_displacements_by_subcase,
|
||
|
|
UNIT_TO_NM,
|
||
|
|
DEFAULT_N_MODES,
|
||
|
|
DEFAULT_FILTER_ORDERS,
|
||
|
|
)
|
||
|
|
|
||
|
|
from optimization_engine.extractors.extract_zernike_trajectory import (
|
||
|
|
ZernikeTrajectoryExtractor,
|
||
|
|
MODE_GROUPS,
|
||
|
|
MODE_NAMES,
|
||
|
|
compute_trajectory_params,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Configuration
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
N_MODES = 50
|
||
|
|
FILTER_LOW_ORDERS = 4
|
||
|
|
PLOT_DOWNSAMPLE = 12000
|
||
|
|
COLORSCALE = 'Turbo'
|
||
|
|
|
||
|
|
# Default design targets (nm)
|
||
|
|
DEFAULT_TARGETS = {
|
||
|
|
'wfe_40_20': 4.0,
|
||
|
|
'wfe_60_20': 10.0,
|
||
|
|
'mfg_90': 20.0,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Default annular aperture for M1 (271.5mm central hole diameter)
|
||
|
|
DEFAULT_INNER_RADIUS = 135.75 # mm
|
||
|
|
|
||
|
|
DISP_UNIT = 'mm'
|
||
|
|
NM_SCALE = UNIT_TO_NM[DISP_UNIT]
|
||
|
|
|
||
|
|
# Subcase mapping: subcase_id -> angle
|
||
|
|
SUBCASE_ANGLE_MAP = {
|
||
|
|
'1': 90, '2': 20, '3': 40, '4': 60,
|
||
|
|
'90': 90, '20': 20, '40': 40, '60': 60,
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── Professional light-theme Plotly layout defaults ──
|
||
|
|
_PLOTLY_LIGHT_LAYOUT = dict(
|
||
|
|
paper_bgcolor='#ffffff',
|
||
|
|
plot_bgcolor='#f8fafc',
|
||
|
|
font=dict(family="Inter, system-ui, sans-serif", color='#1e293b', size=12),
|
||
|
|
)
|
||
|
|
|
||
|
|
# Professional blue palette for charts
|
||
|
|
_BLUE_PALETTE = ['#2563eb', '#3b82f6', '#60a5fa', '#93c5fd', '#1d4ed8', '#1e40af']
|
||
|
|
_MODE_COLORS = ['#2563eb', '#dc2626', '#16a34a', '#9333ea', '#ea580c', '#0891b2', '#4f46e5']
|
||
|
|
_MODE_DASHES = ['solid', 'dash', 'dot', 'dashdot', 'longdash', 'longdashdot', 'solid']
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Data Extraction Helpers
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
def load_study_params(db_path: str, trial_id: int = None) -> dict:
|
||
|
|
"""Load design parameters from study database."""
|
||
|
|
import sqlite3
|
||
|
|
conn = sqlite3.connect(db_path)
|
||
|
|
c = conn.cursor()
|
||
|
|
|
||
|
|
if trial_id is None:
|
||
|
|
# Find best trial by weighted sum
|
||
|
|
c.execute('''
|
||
|
|
SELECT t.trial_id, tua.key, tua.value_json
|
||
|
|
FROM trials t
|
||
|
|
JOIN trial_user_attributes tua ON t.trial_id = tua.trial_id
|
||
|
|
WHERE t.state = 'COMPLETE' AND tua.key = 'weighted_sum'
|
||
|
|
ORDER BY CAST(tua.value_json AS REAL) ASC
|
||
|
|
LIMIT 1
|
||
|
|
''')
|
||
|
|
row = c.fetchone()
|
||
|
|
if row:
|
||
|
|
trial_id = row[0]
|
||
|
|
else:
|
||
|
|
conn.close()
|
||
|
|
return {}
|
||
|
|
|
||
|
|
# Get all attributes for the trial
|
||
|
|
c.execute('''
|
||
|
|
SELECT key, value_json
|
||
|
|
FROM trial_user_attributes
|
||
|
|
WHERE trial_id = ?
|
||
|
|
''', (trial_id,))
|
||
|
|
attrs = {row[0]: json.loads(row[1]) for row in c.fetchall()}
|
||
|
|
|
||
|
|
# Get parameters
|
||
|
|
c.execute('''
|
||
|
|
SELECT tp.key, tp.value_json
|
||
|
|
FROM trial_params tp
|
||
|
|
WHERE tp.trial_id = ?
|
||
|
|
''', (trial_id,))
|
||
|
|
params = {row[0]: json.loads(row[1]) for row in c.fetchall()}
|
||
|
|
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
return {
|
||
|
|
'trial_id': trial_id,
|
||
|
|
'attributes': attrs,
|
||
|
|
'parameters': params,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def build_wfe_arrays(node_ids, disp, node_geo):
|
||
|
|
"""Build X, Y, WFE arrays from displacement data."""
|
||
|
|
X, Y, WFE = [], [], []
|
||
|
|
for nid, vec in zip(node_ids, disp):
|
||
|
|
geo = node_geo.get(int(nid))
|
||
|
|
if geo is None:
|
||
|
|
continue
|
||
|
|
X.append(geo[0])
|
||
|
|
Y.append(geo[1])
|
||
|
|
WFE.append(vec[2] * 2.0 * NM_SCALE)
|
||
|
|
return np.array(X), np.array(Y), np.array(WFE)
|
||
|
|
|
||
|
|
|
||
|
|
def compute_relative_wfe(X1, Y1, WFE1, nids1, X2, Y2, WFE2, nids2):
|
||
|
|
"""Compute WFE1 - WFE2 for common nodes."""
|
||
|
|
ref_map = {int(n): w for n, w in zip(nids2, WFE2)}
|
||
|
|
Xr, Yr, Wr = [], [], []
|
||
|
|
for nid, x, y, w in zip(nids1, X1, Y1, WFE1):
|
||
|
|
nid = int(nid)
|
||
|
|
if nid in ref_map:
|
||
|
|
Xr.append(x)
|
||
|
|
Yr.append(y)
|
||
|
|
Wr.append(w - ref_map[nid])
|
||
|
|
return np.array(Xr), np.array(Yr), np.array(Wr)
|
||
|
|
|
||
|
|
|
||
|
|
def zernike_fit(X, Y, W, n_modes=N_MODES, inner_radius=None):
|
||
|
|
"""Compute Zernike fit with optional annular masking."""
|
||
|
|
Xc = X - np.mean(X)
|
||
|
|
Yc = Y - np.mean(Y)
|
||
|
|
R_outer = float(np.max(np.hypot(Xc, Yc)))
|
||
|
|
r = np.hypot(Xc, Yc) / R_outer
|
||
|
|
th = np.arctan2(Yc, Xc)
|
||
|
|
|
||
|
|
# Annular mask
|
||
|
|
if inner_radius is not None:
|
||
|
|
r_inner_norm = inner_radius / R_outer
|
||
|
|
mask = (r >= r_inner_norm) & (r <= 1.0) & ~np.isnan(W)
|
||
|
|
else:
|
||
|
|
mask = (r <= 1.0) & ~np.isnan(W)
|
||
|
|
|
||
|
|
idx = np.nonzero(mask)[0]
|
||
|
|
m = int(n_modes)
|
||
|
|
G = np.zeros((m, m), dtype=np.float64)
|
||
|
|
h = np.zeros((m,), dtype=np.float64)
|
||
|
|
v = W.astype(np.float64)
|
||
|
|
|
||
|
|
for start in range(0, len(idx), 100000):
|
||
|
|
sl = idx[start:start+100000]
|
||
|
|
Zb = np.column_stack([zernike_noll(j, r[sl].astype(np.float32), th[sl].astype(np.float32)).astype(np.float32)
|
||
|
|
for j in range(1, m+1)])
|
||
|
|
G += (Zb.T @ Zb).astype(np.float64)
|
||
|
|
h += (Zb.T @ v[sl]).astype(np.float64)
|
||
|
|
|
||
|
|
try:
|
||
|
|
coeffs = np.linalg.solve(G, h)
|
||
|
|
except LinAlgError:
|
||
|
|
coeffs = np.linalg.lstsq(G, h, rcond=None)[0]
|
||
|
|
|
||
|
|
# Compute residuals
|
||
|
|
Z_all = np.column_stack([zernike_noll(j, r.astype(np.float32), th.astype(np.float32))
|
||
|
|
for j in range(1, m+1)])
|
||
|
|
W_low4 = Z_all[:, :FILTER_LOW_ORDERS].dot(coeffs[:FILTER_LOW_ORDERS])
|
||
|
|
W_low3 = Z_all[:, :3].dot(coeffs[:3])
|
||
|
|
W_res_j4 = W - W_low4 # J1-J4 removed
|
||
|
|
W_res_j3 = W - W_low3 # J1-J3 removed
|
||
|
|
|
||
|
|
global_rms = float(np.sqrt(np.mean(W[mask]**2)))
|
||
|
|
filtered_rms = float(np.sqrt(np.mean(W_res_j4[mask]**2)))
|
||
|
|
rms_j1to3 = float(np.sqrt(np.mean(W_res_j3[mask]**2)))
|
||
|
|
|
||
|
|
return {
|
||
|
|
'coefficients': coeffs,
|
||
|
|
'R_outer': R_outer,
|
||
|
|
'global_rms': global_rms,
|
||
|
|
'filtered_rms': filtered_rms,
|
||
|
|
'rms_j1to3': rms_j1to3,
|
||
|
|
'W_res_filt': W_res_j4,
|
||
|
|
'mask': mask,
|
||
|
|
'n_masked': int(np.sum(mask)),
|
||
|
|
'n_total': len(W),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def aberration_magnitudes(coeffs):
|
||
|
|
"""Get individual aberration magnitudes from Zernike coefficients."""
|
||
|
|
defocus = float(abs(coeffs[3]))
|
||
|
|
astig = float(np.sqrt(coeffs[4]**2 + coeffs[5]**2))
|
||
|
|
coma = float(np.sqrt(coeffs[6]**2 + coeffs[7]**2))
|
||
|
|
trefoil = float(np.sqrt(coeffs[8]**2 + coeffs[9]**2))
|
||
|
|
spherical = float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0
|
||
|
|
return {
|
||
|
|
'defocus': defocus, 'astigmatism': astig, 'coma': coma,
|
||
|
|
'trefoil': trefoil, 'spherical': spherical,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def compute_spatial_freq_metrics(coefficients):
|
||
|
|
"""Compute spatial frequency band metrics from Zernike coefficients.
|
||
|
|
|
||
|
|
Decomposes the Zernike spectrum into Low Spatial Frequency (form/figure)
|
||
|
|
and Mid Spatial Frequency (ripple) bands based on radial order.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
coefficients: array of Zernike coefficients (50 modes, Noll convention)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict with band RMS values and per-radial-order breakdown
|
||
|
|
"""
|
||
|
|
n_modes = min(len(coefficients), 50)
|
||
|
|
coeffs = np.array(coefficients[:n_modes])
|
||
|
|
|
||
|
|
# Build radial order mapping using noll_indices
|
||
|
|
order_map = {} # n -> list of coeff values
|
||
|
|
for j in range(1, n_modes + 1):
|
||
|
|
n, m = noll_indices(j)
|
||
|
|
if n not in order_map:
|
||
|
|
order_map[n] = []
|
||
|
|
order_map[n].append(coeffs[j - 1])
|
||
|
|
|
||
|
|
# Band definitions (by Noll index, 1-based)
|
||
|
|
# LSF: J4-J15 (n=2..4) — Low Spatial Frequency (form/figure)
|
||
|
|
# MSF: J16-J50 (n=5..9) — Mid Spatial Frequency (ripple)
|
||
|
|
lsf_coeffs = coeffs[3:15] # indices 3..14 → J4..J15
|
||
|
|
msf_coeffs = coeffs[15:n_modes] # indices 15..49 → J16..J50
|
||
|
|
total_coeffs = coeffs[3:n_modes] # J4..J50 (excluding piston/tilt)
|
||
|
|
|
||
|
|
lsf_rms = float(np.sqrt(np.sum(lsf_coeffs**2)))
|
||
|
|
msf_rms = float(np.sqrt(np.sum(msf_coeffs**2)))
|
||
|
|
total_filtered_rms = float(np.sqrt(np.sum(total_coeffs**2)))
|
||
|
|
lsf_msf_ratio = lsf_rms / msf_rms if msf_rms > 0 else float('inf')
|
||
|
|
|
||
|
|
# Per-radial-order RMS
|
||
|
|
per_order = {}
|
||
|
|
for n in sorted(order_map.keys()):
|
||
|
|
order_coeffs = np.array(order_map[n])
|
||
|
|
per_order[n] = float(np.sqrt(np.sum(order_coeffs**2)))
|
||
|
|
|
||
|
|
return {
|
||
|
|
'lsf_rms': lsf_rms,
|
||
|
|
'msf_rms': msf_rms,
|
||
|
|
'total_filtered_rms': total_filtered_rms,
|
||
|
|
'lsf_msf_ratio': lsf_msf_ratio,
|
||
|
|
'per_order': per_order,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _spatial_freq_html(metrics):
|
||
|
|
"""Generate HTML card for spatial frequency band metrics."""
|
||
|
|
m = metrics
|
||
|
|
|
||
|
|
# Per-order breakdown for n=2..9
|
||
|
|
order_items = ""
|
||
|
|
for n in range(2, 10):
|
||
|
|
val = m['per_order'].get(n, 0.0)
|
||
|
|
order_items += (
|
||
|
|
f'<div class="sf-order-item">'
|
||
|
|
f'<span class="sf-order-n">n={n}:</span>'
|
||
|
|
f'<span class="sf-order-val">{val:.2f}</span>'
|
||
|
|
f'</div>\n'
|
||
|
|
)
|
||
|
|
|
||
|
|
ratio_str = (
|
||
|
|
f"{m['lsf_msf_ratio']:.1f}\u00d7"
|
||
|
|
if m['lsf_msf_ratio'] < 1000
|
||
|
|
else "\u221e"
|
||
|
|
)
|
||
|
|
|
||
|
|
return f"""
|
||
|
|
<div class="sf-breakdown">
|
||
|
|
<h4>Spatial Frequency Breakdown</h4>
|
||
|
|
<div class="sf-band-grid">
|
||
|
|
<div class="sf-band-card sf-lsf">
|
||
|
|
<div class="sf-band-label">LSF (Form)</div>
|
||
|
|
<div class="sf-band-range">J4\u2013J15 n\u22644</div>
|
||
|
|
<div class="sf-band-value">{m['lsf_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="sf-band-card sf-msf">
|
||
|
|
<div class="sf-band-label">MSF (Ripple)</div>
|
||
|
|
<div class="sf-band-range">J16\u2013J50 n\u22655</div>
|
||
|
|
<div class="sf-band-value">{m['msf_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="sf-band-card sf-ratio">
|
||
|
|
<div class="sf-band-label">LSF / MSF</div>
|
||
|
|
<div class="sf-band-range">Ratio</div>
|
||
|
|
<div class="sf-band-value">{ratio_str}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="sf-order-grid">
|
||
|
|
<div class="sf-order-title">Per-Order RMS (nm)</div>
|
||
|
|
<div class="sf-order-items">{order_items}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# HTML Report Generation
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
def _metric_color(value, target):
|
||
|
|
"""Return a CSS color class based on value vs target."""
|
||
|
|
if value <= target:
|
||
|
|
return 'color: #16a34a; font-weight: 700;' # green
|
||
|
|
ratio = value / target
|
||
|
|
if ratio < 1.5:
|
||
|
|
return 'color: #d97706; font-weight: 700;' # amber
|
||
|
|
return 'color: #dc2626; font-weight: 700;' # red
|
||
|
|
|
||
|
|
|
||
|
|
def make_surface_plot(X, Y, W_res, mask, inner_radius=None, title="", amp=0.5, downsample=PLOT_DOWNSAMPLE):
|
||
|
|
"""Create a 3D surface plot of residual WFE."""
|
||
|
|
Xm, Ym, Wm = X[mask], Y[mask], W_res[mask]
|
||
|
|
|
||
|
|
n = len(Xm)
|
||
|
|
if n > downsample:
|
||
|
|
rng = np.random.default_rng(42)
|
||
|
|
sel = rng.choice(n, size=downsample, replace=False)
|
||
|
|
Xp, Yp, Wp = Xm[sel], Ym[sel], Wm[sel]
|
||
|
|
else:
|
||
|
|
Xp, Yp, Wp = Xm, Ym, Wm
|
||
|
|
|
||
|
|
res_amp = amp * Wp
|
||
|
|
max_amp = float(np.max(np.abs(res_amp))) if res_amp.size else 1.0
|
||
|
|
|
||
|
|
traces = []
|
||
|
|
try:
|
||
|
|
tri = Triangulation(Xp, Yp)
|
||
|
|
if tri.triangles is not None and len(tri.triangles) > 0:
|
||
|
|
# Filter triangles spanning central hole
|
||
|
|
if inner_radius is not None:
|
||
|
|
cx, cy = np.mean(X), np.mean(Y)
|
||
|
|
valid = []
|
||
|
|
for t in tri.triangles:
|
||
|
|
vx = Xp[t] - cx
|
||
|
|
vy = Yp[t] - cy
|
||
|
|
vr = np.hypot(vx, vy)
|
||
|
|
if np.any(vr < inner_radius * 0.9):
|
||
|
|
continue
|
||
|
|
p0, p1, p2 = Xp[t] + 1j*Yp[t]
|
||
|
|
if max(abs(p1-p0), abs(p2-p1), abs(p0-p2)) > 2*inner_radius:
|
||
|
|
continue
|
||
|
|
valid.append(t)
|
||
|
|
if valid:
|
||
|
|
tri_arr = np.array(valid)
|
||
|
|
else:
|
||
|
|
tri_arr = tri.triangles
|
||
|
|
else:
|
||
|
|
tri_arr = tri.triangles
|
||
|
|
|
||
|
|
i, j, k = tri_arr.T
|
||
|
|
traces.append(go.Mesh3d(
|
||
|
|
x=Xp, y=Yp, z=res_amp,
|
||
|
|
i=i, j=j, k=k,
|
||
|
|
intensity=res_amp,
|
||
|
|
colorscale=COLORSCALE,
|
||
|
|
opacity=1.0,
|
||
|
|
flatshading=False,
|
||
|
|
lighting=dict(ambient=0.4, diffuse=0.8, specular=0.3, roughness=0.5, fresnel=0.2),
|
||
|
|
lightposition=dict(x=100, y=200, z=300),
|
||
|
|
showscale=True,
|
||
|
|
colorbar=dict(title=dict(text="nm", side="right", font=dict(color='#1e293b')), thickness=12, len=0.5, tickformat=".1f",
|
||
|
|
tickfont=dict(color='#1e293b')),
|
||
|
|
hovertemplate="X: %{x:.1f}<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),
|
||
|
|
mode='lines', line=dict(color='#64748b', width=2),
|
||
|
|
name='Central Hole', showlegend=False, hoverinfo='name'
|
||
|
|
))
|
||
|
|
except Exception:
|
||
|
|
traces.append(go.Scatter3d(
|
||
|
|
x=Xp, y=Yp, z=res_amp,
|
||
|
|
mode='markers', marker=dict(size=2, color=res_amp, colorscale=COLORSCALE, showscale=True),
|
||
|
|
showlegend=False
|
||
|
|
))
|
||
|
|
|
||
|
|
fig = go.Figure(data=traces)
|
||
|
|
fig.update_layout(
|
||
|
|
scene=dict(
|
||
|
|
camera=dict(eye=dict(x=1.2, y=1.2, z=0.8), up=dict(x=0, y=0, z=1)),
|
||
|
|
xaxis=dict(title=dict(text="X (mm)", font=dict(color='#1e293b')), showgrid=True, gridcolor='#e2e8f0',
|
||
|
|
showbackground=True, backgroundcolor='#f1f5f9',
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
yaxis=dict(title=dict(text="Y (mm)", font=dict(color='#1e293b')), showgrid=True, gridcolor='#e2e8f0',
|
||
|
|
showbackground=True, backgroundcolor='#f1f5f9',
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
zaxis=dict(title=dict(text="Residual (nm)", font=dict(color='#1e293b')), range=[-max_amp*3.0, max_amp*3.0],
|
||
|
|
showgrid=True, gridcolor='#e2e8f0',
|
||
|
|
showbackground=True, backgroundcolor='#eff6ff',
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
aspectmode='manual',
|
||
|
|
aspectratio=dict(x=1, y=1, z=0.4),
|
||
|
|
),
|
||
|
|
margin=dict(t=30, b=10, l=10, r=10),
|
||
|
|
**_PLOTLY_LIGHT_LAYOUT,
|
||
|
|
height=650,
|
||
|
|
width=1200,
|
||
|
|
)
|
||
|
|
return fig.to_html(include_plotlyjs=False, full_html=False, div_id=f"surface_{title.replace(' ','_')}")
|
||
|
|
|
||
|
|
|
||
|
|
def make_bar_chart(coeffs, title="Zernike Coefficients", max_modes=50):
|
||
|
|
"""Create horizontal bar chart of Zernike coefficient magnitudes."""
|
||
|
|
n = min(len(coeffs), max_modes)
|
||
|
|
labels = [str(zernike_label(j))[:40] for j in range(1, n+1)]
|
||
|
|
vals = np.abs(coeffs[:n])
|
||
|
|
text_vals = [f"{v:.3f}" for v in vals]
|
||
|
|
|
||
|
|
fig = go.Figure(go.Bar(
|
||
|
|
x=vals, y=labels, orientation='h',
|
||
|
|
marker_color=_BLUE_PALETTE[0],
|
||
|
|
text=text_vals,
|
||
|
|
textposition='outside',
|
||
|
|
textfont=dict(size=9, color='#475569'),
|
||
|
|
hovertemplate="%{y}<br>|c| = %{x:.3f} nm<extra></extra>",
|
||
|
|
))
|
||
|
|
fig.update_layout(
|
||
|
|
height=max(400, n*22),
|
||
|
|
margin=dict(t=30, b=10, l=220, r=60),
|
||
|
|
**_PLOTLY_LIGHT_LAYOUT,
|
||
|
|
xaxis=dict(title=dict(text="|Coefficient| (nm)", font=dict(color='#1e293b')),
|
||
|
|
gridcolor='#e2e8f0', zeroline=True,
|
||
|
|
zerolinecolor='#cbd5e1',
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
yaxis=dict(autorange='reversed', tickfont=dict(size=10, color='#1e293b')),
|
||
|
|
)
|
||
|
|
return fig.to_html(include_plotlyjs=False, full_html=False, div_id=f"bar_{title.replace(' ','_')}")
|
||
|
|
|
||
|
|
|
||
|
|
def make_trajectory_plot(angles, coefficients_relative, mode_groups, sensitivity, title=""):
|
||
|
|
"""Create trajectory visualization: Zernike modes vs elevation angle."""
|
||
|
|
fig = go.Figure()
|
||
|
|
|
||
|
|
color_idx = 0
|
||
|
|
|
||
|
|
for group_name, noll_indices in mode_groups.items():
|
||
|
|
indices = [n - 5 for n in noll_indices if 5 <= n < 5 + coefficients_relative.shape[1]]
|
||
|
|
if not indices:
|
||
|
|
continue
|
||
|
|
|
||
|
|
color = _MODE_COLORS[color_idx % len(_MODE_COLORS)]
|
||
|
|
dash = _MODE_DASHES[color_idx % len(_MODE_DASHES)]
|
||
|
|
|
||
|
|
# RSS of modes in this group at each angle
|
||
|
|
rss = np.sqrt(np.sum(coefficients_relative[:, indices]**2, axis=1))
|
||
|
|
display_name = MODE_NAMES.get(group_name, group_name)
|
||
|
|
|
||
|
|
# Group RSS trace (thick)
|
||
|
|
fig.add_trace(go.Scatter(
|
||
|
|
x=angles, y=rss,
|
||
|
|
mode='lines+markers',
|
||
|
|
name=f"{display_name} (RSS)",
|
||
|
|
line=dict(color=color, width=3, dash=dash),
|
||
|
|
marker=dict(size=10, symbol='circle'),
|
||
|
|
hovertemplate=f"{display_name} RSS<br>%{{x:.1f}}°: %{{y:.3f}} nm<extra></extra>",
|
||
|
|
legendgroup=group_name,
|
||
|
|
))
|
||
|
|
|
||
|
|
# Individual mode traces (thin, same color, lighter)
|
||
|
|
for idx_i, noll_idx in enumerate(noll_indices):
|
||
|
|
col_idx = noll_idx - 5
|
||
|
|
if col_idx < 0 or col_idx >= coefficients_relative.shape[1]:
|
||
|
|
continue
|
||
|
|
mode_vals = np.abs(coefficients_relative[:, col_idx])
|
||
|
|
mode_label = f"J{noll_idx}"
|
||
|
|
fig.add_trace(go.Scatter(
|
||
|
|
x=angles, y=mode_vals,
|
||
|
|
mode='lines+markers',
|
||
|
|
name=f" {mode_label}",
|
||
|
|
line=dict(color=color, width=1, dash='dot'),
|
||
|
|
marker=dict(size=5, symbol='diamond'),
|
||
|
|
opacity=0.5,
|
||
|
|
hovertemplate=f"{mode_label}<br>%{{x:.1f}}°: %{{y:.3f}} nm<extra></extra>",
|
||
|
|
legendgroup=group_name,
|
||
|
|
showlegend=True,
|
||
|
|
))
|
||
|
|
|
||
|
|
color_idx += 1
|
||
|
|
|
||
|
|
# Check if log scale would help (values span >2 orders of magnitude)
|
||
|
|
all_y = []
|
||
|
|
for trace in fig.data:
|
||
|
|
all_y.extend([v for v in trace.y if v > 0])
|
||
|
|
use_log = False
|
||
|
|
if all_y:
|
||
|
|
y_min, y_max = min(all_y), max(all_y)
|
||
|
|
if y_max > 0 and y_min > 0 and (y_max / y_min) > 100:
|
||
|
|
use_log = True
|
||
|
|
|
||
|
|
yaxis_cfg = dict(
|
||
|
|
title=dict(text="RMS (nm)", font=dict(color='#1e293b')),
|
||
|
|
gridcolor='#e2e8f0',
|
||
|
|
zeroline=True, zerolinecolor='#cbd5e1',
|
||
|
|
tickfont=dict(color='#475569'),
|
||
|
|
)
|
||
|
|
if use_log:
|
||
|
|
yaxis_cfg['type'] = 'log'
|
||
|
|
yaxis_cfg['title'] = dict(text="RMS (nm) — log scale", font=dict(color='#1e293b'))
|
||
|
|
|
||
|
|
fig.update_layout(
|
||
|
|
height=450,
|
||
|
|
margin=dict(t=30, b=50, l=70, r=20),
|
||
|
|
**_PLOTLY_LIGHT_LAYOUT,
|
||
|
|
xaxis=dict(title=dict(text="Elevation Angle (°)", font=dict(color='#1e293b')),
|
||
|
|
gridcolor='#e2e8f0',
|
||
|
|
tickvals=list(angles), dtick=10,
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
yaxis=yaxis_cfg,
|
||
|
|
legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.9)',
|
||
|
|
bordercolor='#e2e8f0', borderwidth=1,
|
||
|
|
font=dict(size=10, color='#1e293b')),
|
||
|
|
)
|
||
|
|
return fig.to_html(include_plotlyjs=False, full_html=False, div_id="trajectory_plot")
|
||
|
|
|
||
|
|
|
||
|
|
def make_sensitivity_bar(sensitivity_dict):
|
||
|
|
"""Create stacked bar chart of axial vs lateral sensitivity per mode."""
|
||
|
|
modes = list(sensitivity_dict.keys())
|
||
|
|
axial = [sensitivity_dict[m]['axial'] for m in modes]
|
||
|
|
lateral = [sensitivity_dict[m]['lateral'] for m in modes]
|
||
|
|
labels = [str(MODE_NAMES.get(m, m)) for m in modes]
|
||
|
|
|
||
|
|
fig = go.Figure()
|
||
|
|
fig.add_trace(go.Bar(
|
||
|
|
y=labels, x=axial, orientation='h',
|
||
|
|
name='Axial (sin \u03b8)', marker_color='#2563eb',
|
||
|
|
text=[f"{v:.3f}" for v in axial], textposition='outside',
|
||
|
|
textfont=dict(size=9, color='#475569'),
|
||
|
|
hovertemplate="%{y}<br>Axial: %{x:.3f} nm/unit<extra></extra>"
|
||
|
|
))
|
||
|
|
fig.add_trace(go.Bar(
|
||
|
|
y=labels, x=lateral, orientation='h',
|
||
|
|
name='Lateral (cos \u03b8)', marker_color='#7c3aed',
|
||
|
|
text=[f"{v:.3f}" for v in lateral], textposition='outside',
|
||
|
|
textfont=dict(size=9, color='#475569'),
|
||
|
|
hovertemplate="%{y}<br>Lateral: %{x:.3f} nm/unit<extra></extra>"
|
||
|
|
))
|
||
|
|
fig.update_layout(
|
||
|
|
barmode='group',
|
||
|
|
height=max(300, len(modes)*45),
|
||
|
|
margin=dict(t=30, b=40, l=180, r=60),
|
||
|
|
**_PLOTLY_LIGHT_LAYOUT,
|
||
|
|
xaxis=dict(title=dict(text="Sensitivity (nm per load fraction)", font=dict(color='#1e293b')),
|
||
|
|
gridcolor='#e2e8f0',
|
||
|
|
zeroline=True, zerolinecolor='#cbd5e1',
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
yaxis=dict(autorange='reversed', tickfont=dict(size=11, color='#1e293b')),
|
||
|
|
legend=dict(x=0.55, y=0.99, bgcolor='rgba(255,255,255,0.9)',
|
||
|
|
bordercolor='#e2e8f0', borderwidth=1,
|
||
|
|
font=dict(color='#1e293b')),
|
||
|
|
)
|
||
|
|
return fig.to_html(include_plotlyjs=False, full_html=False, div_id="sensitivity_bar")
|
||
|
|
|
||
|
|
|
||
|
|
def make_per_angle_rms_plot(angle_rms_data, ref_angle=20):
|
||
|
|
"""Create bar chart of per-angle RMS relative to reference."""
|
||
|
|
angles = sorted(angle_rms_data.keys())
|
||
|
|
rms_vals = [angle_rms_data[a] for a in angles]
|
||
|
|
labels = [f"{a}\u00b0 vs {ref_angle}\u00b0" for a in angles]
|
||
|
|
|
||
|
|
fig = go.Figure(go.Bar(
|
||
|
|
x=labels, y=rms_vals,
|
||
|
|
marker_color=_BLUE_PALETTE[0],
|
||
|
|
text=[f"{v:.2f} nm" for v in rms_vals],
|
||
|
|
textposition='outside',
|
||
|
|
textfont=dict(size=11, color='#1e293b'),
|
||
|
|
hovertemplate="%{x}: %{y:.2f} nm<extra></extra>"
|
||
|
|
))
|
||
|
|
fig.update_layout(
|
||
|
|
height=350,
|
||
|
|
margin=dict(t=30, b=40, l=60, r=20),
|
||
|
|
**_PLOTLY_LIGHT_LAYOUT,
|
||
|
|
yaxis=dict(title=dict(text="Filtered RMS WFE (nm)", font=dict(color='#1e293b')),
|
||
|
|
gridcolor='#e2e8f0',
|
||
|
|
zeroline=True, zerolinecolor='#cbd5e1',
|
||
|
|
tickfont=dict(color='#475569')),
|
||
|
|
xaxis=dict(tickfont=dict(color='#1e293b', size=12)),
|
||
|
|
)
|
||
|
|
return fig.to_html(include_plotlyjs=False, full_html=False, div_id="per_angle_rms")
|
||
|
|
|
||
|
|
|
||
|
|
# ============================================================================
|
||
|
|
# Main Report Builder
|
||
|
|
# ============================================================================
|
||
|
|
|
||
|
|
def generate_report(
|
||
|
|
op2_path: Path,
|
||
|
|
inner_radius: float = None,
|
||
|
|
targets: dict = None,
|
||
|
|
study_db: str = None,
|
||
|
|
trial_id: int = None,
|
||
|
|
title: str = "M1 Mirror Optical Performance Report",
|
||
|
|
study_name: str = None,
|
||
|
|
) -> Path:
|
||
|
|
"""Generate comprehensive optical performance HTML report."""
|
||
|
|
|
||
|
|
targets = targets or DEFAULT_TARGETS
|
||
|
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
|
|
ts_file = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
|
|
|
||
|
|
print("=" * 70)
|
||
|
|
print(" ATOMIZER OPTICAL PERFORMANCE REPORT GENERATOR")
|
||
|
|
print("=" * 70)
|
||
|
|
print(f"\nOP2 File: {op2_path.name}")
|
||
|
|
print(f"Inner Radius: {inner_radius} mm" if inner_radius else "Aperture: Full disk")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# 1. Load geometry & displacement data
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
print("\n[1/5] Loading data...")
|
||
|
|
geo_path = find_geometry_file(op2_path)
|
||
|
|
node_geo = read_node_geometry(geo_path)
|
||
|
|
print(f" Geometry: {geo_path.name} ({len(node_geo)} nodes)")
|
||
|
|
|
||
|
|
op2 = OP2()
|
||
|
|
op2.read_op2(str(op2_path))
|
||
|
|
displacements = extract_displacements_by_subcase(op2_path)
|
||
|
|
print(f" Subcases: {list(displacements.keys())}")
|
||
|
|
|
||
|
|
# Map subcases to angles
|
||
|
|
subcase_map = {}
|
||
|
|
for angle in ['90', '20', '40', '60']:
|
||
|
|
if angle in displacements:
|
||
|
|
subcase_map[angle] = angle
|
||
|
|
if len(subcase_map) < 4:
|
||
|
|
if all(str(i) in displacements for i in range(1, 5)):
|
||
|
|
subcase_map = {'90': '1', '20': '2', '40': '3', '60': '4'}
|
||
|
|
print(f" Subcase map: {subcase_map}")
|
||
|
|
|
||
|
|
# Also detect intermediate angles (30, 50) if present
|
||
|
|
extra_angles = []
|
||
|
|
for a in ['30', '50']:
|
||
|
|
if a in displacements:
|
||
|
|
extra_angles.append(a)
|
||
|
|
if extra_angles:
|
||
|
|
print(f" Extra angles detected: {extra_angles}")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# 2. Per-angle Zernike analysis
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
print("\n[2/5] Per-angle Zernike analysis...")
|
||
|
|
|
||
|
|
ref_label = subcase_map['20']
|
||
|
|
ref_data = displacements[ref_label]
|
||
|
|
X_ref, Y_ref, WFE_ref = build_wfe_arrays(ref_data['node_ids'], ref_data['disp'], node_geo)
|
||
|
|
|
||
|
|
# Analysis results storage
|
||
|
|
angle_results = {} # angle -> {rms_data, X, Y, WFE, ...}
|
||
|
|
|
||
|
|
for angle_name, label in subcase_map.items():
|
||
|
|
data = displacements[label]
|
||
|
|
X, Y, WFE = build_wfe_arrays(data['node_ids'], data['disp'], node_geo)
|
||
|
|
|
||
|
|
# Absolute fit
|
||
|
|
rms_abs = zernike_fit(X, Y, WFE, inner_radius=inner_radius)
|
||
|
|
|
||
|
|
# Relative fit (vs 20 deg reference)
|
||
|
|
if angle_name != '20':
|
||
|
|
Xr, Yr, Wr = compute_relative_wfe(
|
||
|
|
X, Y, WFE, data['node_ids'],
|
||
|
|
X_ref, Y_ref, WFE_ref, ref_data['node_ids']
|
||
|
|
)
|
||
|
|
rms_rel = zernike_fit(Xr, Yr, Wr, inner_radius=inner_radius)
|
||
|
|
else:
|
||
|
|
Xr, Yr, Wr = X, Y, np.zeros_like(WFE)
|
||
|
|
rms_rel = {'filtered_rms': 0.0, 'rms_j1to3': 0.0, 'coefficients': np.zeros(N_MODES)}
|
||
|
|
|
||
|
|
angle_results[int(angle_name)] = {
|
||
|
|
'X': X, 'Y': Y, 'WFE': WFE,
|
||
|
|
'X_rel': Xr, 'Y_rel': Yr, 'WFE_rel': Wr,
|
||
|
|
'rms_abs': rms_abs,
|
||
|
|
'rms_rel': rms_rel,
|
||
|
|
'aberrations_abs': aberration_magnitudes(rms_abs['coefficients']),
|
||
|
|
'aberrations_rel': aberration_magnitudes(rms_rel['coefficients']) if angle_name != '20' else None,
|
||
|
|
}
|
||
|
|
print(f" {angle_name}° - Abs Filt: {rms_abs['filtered_rms']:.2f} nm, "
|
||
|
|
f"Rel Filt: {rms_rel['filtered_rms']:.2f} nm")
|
||
|
|
|
||
|
|
# Extra angles (30, 50)
|
||
|
|
for ea in extra_angles:
|
||
|
|
data = displacements[ea]
|
||
|
|
X, Y, WFE = build_wfe_arrays(data['node_ids'], data['disp'], node_geo)
|
||
|
|
Xr, Yr, Wr = compute_relative_wfe(
|
||
|
|
X, Y, WFE, data['node_ids'],
|
||
|
|
X_ref, Y_ref, WFE_ref, ref_data['node_ids']
|
||
|
|
)
|
||
|
|
rms_abs = zernike_fit(X, Y, WFE, inner_radius=inner_radius)
|
||
|
|
rms_rel = zernike_fit(Xr, Yr, Wr, inner_radius=inner_radius)
|
||
|
|
angle_results[int(ea)] = {
|
||
|
|
'X': X, 'Y': Y, 'WFE': WFE,
|
||
|
|
'X_rel': Xr, 'Y_rel': Yr, 'WFE_rel': Wr,
|
||
|
|
'rms_abs': rms_abs,
|
||
|
|
'rms_rel': rms_rel,
|
||
|
|
'aberrations_abs': aberration_magnitudes(rms_abs['coefficients']),
|
||
|
|
'aberrations_rel': aberration_magnitudes(rms_rel['coefficients']),
|
||
|
|
}
|
||
|
|
print(f" {ea}° - Abs Filt: {rms_abs['filtered_rms']:.2f} nm, "
|
||
|
|
f"Rel Filt: {rms_rel['filtered_rms']:.2f} nm")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# 3. Trajectory analysis
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
print("\n[3/5] Trajectory analysis...")
|
||
|
|
traj_result = None
|
||
|
|
try:
|
||
|
|
traj_extractor = ZernikeTrajectoryExtractor(
|
||
|
|
op2_file=op2_path,
|
||
|
|
bdf_file=geo_path,
|
||
|
|
reference_angle=20.0,
|
||
|
|
unit=DISP_UNIT,
|
||
|
|
n_modes=N_MODES,
|
||
|
|
inner_radius=inner_radius,
|
||
|
|
)
|
||
|
|
traj_result = traj_extractor.extract_trajectory(exclude_angles=[90.0])
|
||
|
|
print(f" R² fit: {traj_result['linear_fit_r2']:.4f}")
|
||
|
|
print(f" Dominant mode: {traj_result['dominant_mode']}")
|
||
|
|
print(f" Total filtered RMS: {traj_result['total_filtered_rms_nm']:.2f} nm")
|
||
|
|
except Exception as e:
|
||
|
|
print(f" [WARN] Trajectory analysis failed: {e}")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# 4. Manufacturing analysis
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
print("\n[4/5] Manufacturing analysis...")
|
||
|
|
r90 = angle_results[90]
|
||
|
|
mfg_abs_aberr = r90['aberrations_abs']
|
||
|
|
mfg_correction = aberration_magnitudes(angle_results[90]['rms_rel']['coefficients'])
|
||
|
|
mfg_rms_j1to3 = r90['rms_rel']['rms_j1to3']
|
||
|
|
print(f" MFG 90 (J1-J3 filtered): {mfg_rms_j1to3:.2f} nm")
|
||
|
|
print(f" Correction - Astigmatism: {mfg_correction['astigmatism']:.2f} nm, "
|
||
|
|
f"Coma: {mfg_correction['coma']:.2f} nm")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# 5. Load study params (optional)
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
study_params = None
|
||
|
|
if study_db:
|
||
|
|
print("\n[5/5] Loading study parameters...")
|
||
|
|
try:
|
||
|
|
study_params = load_study_params(study_db, trial_id)
|
||
|
|
print(f" Trial #{study_params.get('trial_id', '?')}")
|
||
|
|
except Exception as e:
|
||
|
|
print(f" [WARN] Could not load study params: {e}")
|
||
|
|
else:
|
||
|
|
print("\n[5/5] No study database provided (skipping design parameters)")
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Key metrics for executive summary
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
wfe_40_20 = angle_results[40]['rms_rel']['filtered_rms']
|
||
|
|
wfe_60_20 = angle_results[60]['rms_rel']['filtered_rms']
|
||
|
|
mfg_90 = mfg_rms_j1to3
|
||
|
|
|
||
|
|
# Weighted sum
|
||
|
|
ws = 6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Generate HTML
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
print("\nGenerating HTML report...")
|
||
|
|
|
||
|
|
# Surface plots
|
||
|
|
surf_40 = make_surface_plot(
|
||
|
|
angle_results[40]['X_rel'], angle_results[40]['Y_rel'],
|
||
|
|
angle_results[40]['rms_rel']['W_res_filt'], angle_results[40]['rms_rel']['mask'],
|
||
|
|
inner_radius=inner_radius, title="40 vs 20"
|
||
|
|
)
|
||
|
|
surf_60 = make_surface_plot(
|
||
|
|
angle_results[60]['X_rel'], angle_results[60]['Y_rel'],
|
||
|
|
angle_results[60]['rms_rel']['W_res_filt'], angle_results[60]['rms_rel']['mask'],
|
||
|
|
inner_radius=inner_radius, title="60 vs 20"
|
||
|
|
)
|
||
|
|
surf_90 = make_surface_plot(
|
||
|
|
angle_results[90]['X'], angle_results[90]['Y'],
|
||
|
|
angle_results[90]['rms_abs']['W_res_filt'], angle_results[90]['rms_abs']['mask'],
|
||
|
|
inner_radius=inner_radius, title="90 abs"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Bar charts
|
||
|
|
bar_40 = make_bar_chart(angle_results[40]['rms_rel']['coefficients'], title="40v20 coeffs")
|
||
|
|
bar_60 = make_bar_chart(angle_results[60]['rms_rel']['coefficients'], title="60v20 coeffs")
|
||
|
|
bar_90 = make_bar_chart(angle_results[90]['rms_abs']['coefficients'], title="90abs coeffs")
|
||
|
|
|
||
|
|
# Spatial frequency band metrics
|
||
|
|
sfm_40 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[40]['rms_rel']['coefficients']))
|
||
|
|
sfm_60 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[60]['rms_rel']['coefficients']))
|
||
|
|
sfm_90 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[90]['rms_abs']['coefficients']))
|
||
|
|
|
||
|
|
# Per-angle RMS plot
|
||
|
|
angle_rms_data = {}
|
||
|
|
for ang in sorted(angle_results.keys()):
|
||
|
|
if ang != 20:
|
||
|
|
angle_rms_data[ang] = angle_results[ang]['rms_rel']['filtered_rms']
|
||
|
|
per_angle_plot = make_per_angle_rms_plot(angle_rms_data)
|
||
|
|
|
||
|
|
# Trajectory & sensitivity plots
|
||
|
|
traj_plot_html = ""
|
||
|
|
sens_plot_html = ""
|
||
|
|
if traj_result:
|
||
|
|
coeffs_rel = np.array(traj_result['coefficients_relative'])
|
||
|
|
traj_plot_html = make_trajectory_plot(
|
||
|
|
traj_result['angles_deg'], coeffs_rel, MODE_GROUPS,
|
||
|
|
traj_result['sensitivity_matrix']
|
||
|
|
)
|
||
|
|
sens_plot_html = make_sensitivity_bar(traj_result['sensitivity_matrix'])
|
||
|
|
|
||
|
|
# Design parameters table
|
||
|
|
params_html = ""
|
||
|
|
if study_params and study_params.get('parameters'):
|
||
|
|
params = study_params['parameters']
|
||
|
|
rows = ""
|
||
|
|
for k, v in sorted(params.items()):
|
||
|
|
unit = "\u00b0" if "angle" in k else "mm"
|
||
|
|
rows += f"<tr><td>{k}</td><td>{v:.4f} {unit}</td></tr>\n"
|
||
|
|
params_html = f"""
|
||
|
|
<div class="section">
|
||
|
|
<h2>7. Design Parameters (Trial #{study_params.get('trial_id', '?')})</h2>
|
||
|
|
<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>
|
||
|
|
<td><b>{ang}\u00b0</b></td>
|
||
|
|
<td>{abs_glob:.2f}</td><td>{abs_filt:.2f}</td>
|
||
|
|
<td>{rel_filt:.2f}</td>
|
||
|
|
<td>{ab['astigmatism']:.2f}</td><td>{ab['coma']:.2f}</td>
|
||
|
|
<td>{ab['trefoil']:.2f}</td><td>{ab['spherical']:.2f}</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']:.2f} nm</div>
|
||
|
|
</div>
|
||
|
|
<div class="metric-card">
|
||
|
|
<div class="metric-label">Astigmatism RMS</div>
|
||
|
|
<div class="metric-value">{traj_result['astigmatism_rms_nm']:.2f} nm</div>
|
||
|
|
</div>
|
||
|
|
<div class="metric-card">
|
||
|
|
<div class="metric-label">Trefoil RMS</div>
|
||
|
|
<div class="metric-value">{traj_result['trefoil_rms_nm']:.2f} nm</div>
|
||
|
|
</div>
|
||
|
|
<div class="metric-card">
|
||
|
|
<div class="metric-label">Spherical RMS</div>
|
||
|
|
<div class="metric-value">{traj_result['spherical_rms_nm']:.2f} 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']:.2f} nm</div>
|
||
|
|
</div>
|
||
|
|
<div class="metric-card">
|
||
|
|
<div class="metric-label">Linear Fit R\u00b2</div>
|
||
|
|
<div class="metric-value">{traj_result['linear_fit_r2']:.4f}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p class="note">Dominant aberration mode: <b>{MODE_NAMES.get(traj_result['dominant_mode'], traj_result['dominant_mode'])}</b></p>
|
||
|
|
<p class="note">Mode ranking: {' \u2192 '.join(traj_result['mode_ranking'][:5])}</p>
|
||
|
|
"""
|
||
|
|
|
||
|
|
# Manufacturing details
|
||
|
|
mfg_html = f"""
|
||
|
|
<table class="data-table">
|
||
|
|
<thead><tr><th>Metric</th><th>Absolute 90\u00b0</th><th>Correction (90\u00b0\u221220\u00b0)</th></tr></thead>
|
||
|
|
<tbody>
|
||
|
|
<tr><td>Defocus (J4)</td><td>{mfg_abs_aberr['defocus']:.2f} nm</td><td>{mfg_correction['defocus']:.2f} nm</td></tr>
|
||
|
|
<tr><td>Astigmatism (J5+J6)</td><td>{mfg_abs_aberr['astigmatism']:.2f} nm</td><td>{mfg_correction['astigmatism']:.2f} nm</td></tr>
|
||
|
|
<tr><td>Coma (J7+J8)</td><td>{mfg_abs_aberr['coma']:.2f} nm</td><td>{mfg_correction['coma']:.2f} nm</td></tr>
|
||
|
|
<tr><td>Trefoil (J9+J10)</td><td>{mfg_abs_aberr['trefoil']:.2f} nm</td><td>{mfg_correction['trefoil']:.2f} nm</td></tr>
|
||
|
|
<tr><td>Spherical (J11)</td><td>{mfg_abs_aberr['spherical']:.2f} nm</td><td>{mfg_correction['spherical']:.2f} nm</td></tr>
|
||
|
|
<tr class="highlight"><td><b>J1\u2212J3 Filtered RMS</b></td><td>{r90['rms_abs']['rms_j1to3']:.2f} nm</td><td><b>{mfg_rms_j1to3:.2f} nm</b></td></tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
"""
|
||
|
|
|
||
|
|
# Executive summary metric styling
|
||
|
|
style_40 = _metric_color(wfe_40_20, targets['wfe_40_20'])
|
||
|
|
style_60 = _metric_color(wfe_60_20, targets['wfe_60_20'])
|
||
|
|
style_mfg = _metric_color(mfg_90, targets['mfg_90'])
|
||
|
|
|
||
|
|
# Section numbering: adjust if trajectory present
|
||
|
|
sec_surface = 3
|
||
|
|
sec_traj = 4
|
||
|
|
sec_mfg = 5 if traj_result else 4
|
||
|
|
sec_params = sec_mfg + 1
|
||
|
|
sec_zernike = sec_params + 1 if (study_params and study_params.get('parameters')) else sec_mfg + 1
|
||
|
|
sec_method = sec_zernike + 1
|
||
|
|
|
||
|
|
# Assemble full HTML
|
||
|
|
html = f"""<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>{title}</title>
|
||
|
|
<script src="https://cdn.plot.ly/plotly-3.3.1.min.js"></script>
|
||
|
|
<style>
|
||
|
|
:root {{
|
||
|
|
--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;
|
||
|
|
}}
|
||
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
|
|
body {{
|
||
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
color: var(--text-primary);
|
||
|
|
line-height: 1.6;
|
||
|
|
}}
|
||
|
|
.container {{ max-width: 1400px; margin: 0 auto; padding: 2rem; }}
|
||
|
|
|
||
|
|
/* Header */
|
||
|
|
.header {{
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 2rem 3rem;
|
||
|
|
margin-bottom: 2rem;
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
}}
|
||
|
|
.header h1 {{ font-size: 1.6rem; font-weight: 700; color: var(--text-primary); }}
|
||
|
|
.header .subtitle {{ color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.3rem; }}
|
||
|
|
.header .branding {{
|
||
|
|
text-align: right;
|
||
|
|
font-size: 0.85rem;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}}
|
||
|
|
.header .branding .by-line {{ color: #94a3b8; font-size: 0.8rem; margin-top: 0.2rem; }}
|
||
|
|
.header .branding .tagline {{ color: var(--text-secondary); font-size: 0.8rem; margin-top: 0.15rem; }}
|
||
|
|
|
||
|
|
/* Sections */
|
||
|
|
.section {{
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 1.5rem 2rem;
|
||
|
|
margin-bottom: 1.5rem;
|
||
|
|
}}
|
||
|
|
.section h2 {{
|
||
|
|
font-size: 1.2rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text-primary);
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
padding-bottom: 0.5rem;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
}}
|
||
|
|
|
||
|
|
/* Executive Summary */
|
||
|
|
.exec-grid {{
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
|
|
gap: 1rem;
|
||
|
|
margin: 1rem 0;
|
||
|
|
}}
|
||
|
|
.exec-card {{
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 1.2rem;
|
||
|
|
}}
|
||
|
|
.exec-card .label {{ font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.3rem; }}
|
||
|
|
.exec-card .value {{ font-size: 1.8rem; font-weight: 700; }}
|
||
|
|
.exec-card .unit {{ font-size: 0.9rem; font-weight: 400; color: var(--text-secondary); }}
|
||
|
|
.exec-footnote {{
|
||
|
|
margin-top: 1rem;
|
||
|
|
padding: 0.75rem 1rem;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border-left: 3px solid var(--accent);
|
||
|
|
border-radius: 0 4px 4px 0;
|
||
|
|
font-size: 0.82rem;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}}
|
||
|
|
|
||
|
|
/* Tables */
|
||
|
|
.data-table {{
|
||
|
|
width: 100%;
|
||
|
|
border-collapse: collapse;
|
||
|
|
margin: 0.5rem 0;
|
||
|
|
}}
|
||
|
|
.data-table th, .data-table td {{
|
||
|
|
padding: 0.6rem 1rem;
|
||
|
|
text-align: left;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
}}
|
||
|
|
.data-table th {{
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
font-weight: 600;
|
||
|
|
font-size: 0.82rem;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.05em;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}}
|
||
|
|
.data-table tr:hover {{ background: #f1f5f9; }}
|
||
|
|
.data-table tr.highlight td {{
|
||
|
|
background: var(--accent-light);
|
||
|
|
font-weight: 600;
|
||
|
|
}}
|
||
|
|
|
||
|
|
/* Metrics Grid */
|
||
|
|
.metrics-grid {{
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||
|
|
gap: 0.8rem;
|
||
|
|
margin: 1rem 0;
|
||
|
|
}}
|
||
|
|
.metric-card {{
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 1rem;
|
||
|
|
text-align: center;
|
||
|
|
}}
|
||
|
|
.metric-label {{ font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.3rem; }}
|
||
|
|
.metric-value {{ font-size: 1.2rem; font-weight: 700; color: var(--accent); }}
|
||
|
|
|
||
|
|
/* Plots */
|
||
|
|
.plot-grid {{
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fit, minmax(1100px, 1fr));
|
||
|
|
gap: 1rem;
|
||
|
|
}}
|
||
|
|
.plot-container {{
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 1rem;
|
||
|
|
min-height: 400px;
|
||
|
|
}}
|
||
|
|
.plot-container h3 {{
|
||
|
|
font-size: 0.95rem;
|
||
|
|
font-weight: 600;
|
||
|
|
margin-bottom: 0.5rem;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}}
|
||
|
|
|
||
|
|
/* Spatial Frequency Breakdown */
|
||
|
|
.sf-breakdown {{
|
||
|
|
margin: 1rem 0 1.5rem 0;
|
||
|
|
padding: 1.2rem;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 8px;
|
||
|
|
}}
|
||
|
|
.sf-breakdown h4 {{
|
||
|
|
font-size: 0.9rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.05em;
|
||
|
|
margin-bottom: 0.8rem;
|
||
|
|
}}
|
||
|
|
.sf-band-grid {{
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 1fr 1fr auto;
|
||
|
|
gap: 0.8rem;
|
||
|
|
margin-bottom: 1rem;
|
||
|
|
}}
|
||
|
|
.sf-band-card {{
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 0.8rem 1rem;
|
||
|
|
text-align: center;
|
||
|
|
}}
|
||
|
|
.sf-band-label {{
|
||
|
|
font-size: 0.82rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text-primary);
|
||
|
|
}}
|
||
|
|
.sf-band-range {{
|
||
|
|
font-size: 0.75rem;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
margin: 0.15rem 0 0.4rem 0;
|
||
|
|
}}
|
||
|
|
.sf-band-value {{
|
||
|
|
font-size: 1.3rem;
|
||
|
|
font-weight: 700;
|
||
|
|
color: var(--accent);
|
||
|
|
}}
|
||
|
|
.sf-unit {{
|
||
|
|
font-size: 0.8rem;
|
||
|
|
font-weight: 400;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
}}
|
||
|
|
.sf-lsf {{ border-top: 3px solid #2563eb; }}
|
||
|
|
.sf-msf {{ border-top: 3px solid #7c3aed; }}
|
||
|
|
.sf-ratio {{ border-top: 3px solid #16a34a; min-width: 100px; }}
|
||
|
|
.sf-order-grid {{
|
||
|
|
background: var(--bg-card);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 0.8rem 1rem;
|
||
|
|
}}
|
||
|
|
.sf-order-title {{
|
||
|
|
font-size: 0.8rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
margin-bottom: 0.5rem;
|
||
|
|
}}
|
||
|
|
.sf-order-items {{
|
||
|
|
display: flex;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
gap: 0.3rem 1.2rem;
|
||
|
|
}}
|
||
|
|
.sf-order-item {{
|
||
|
|
display: flex;
|
||
|
|
gap: 0.3rem;
|
||
|
|
font-size: 0.85rem;
|
||
|
|
}}
|
||
|
|
.sf-order-n {{
|
||
|
|
color: var(--text-secondary);
|
||
|
|
font-weight: 500;
|
||
|
|
}}
|
||
|
|
.sf-order-val {{
|
||
|
|
color: var(--text-primary);
|
||
|
|
font-weight: 600;
|
||
|
|
}}
|
||
|
|
|
||
|
|
/* Collapsible */
|
||
|
|
details {{ margin: 0.5rem 0; }}
|
||
|
|
summary {{
|
||
|
|
cursor: pointer;
|
||
|
|
font-weight: 600;
|
||
|
|
padding: 0.6rem 0.8rem;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border-radius: 6px;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
color: var(--text-primary);
|
||
|
|
font-size: 0.95rem;
|
||
|
|
}}
|
||
|
|
summary:hover {{ background: var(--accent-light); }}
|
||
|
|
details > div {{ padding: 1rem; }}
|
||
|
|
|
||
|
|
.note {{ color: var(--text-secondary); font-size: 0.88rem; margin: 0.5rem 0; }}
|
||
|
|
|
||
|
|
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }}
|
||
|
|
@media (max-width: 900px) {{ .two-col {{ grid-template-columns: 1fr; }} }}
|
||
|
|
|
||
|
|
/* Print styles */
|
||
|
|
@media print {{
|
||
|
|
body {{ background: white; color: black; font-size: 10pt; }}
|
||
|
|
.container {{ max-width: 100%; padding: 0.5cm; }}
|
||
|
|
.header {{ border: 1px solid #999; }}
|
||
|
|
.section {{ border: 1px 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, 1fr); }}
|
||
|
|
.no-print {{ display: none; }}
|
||
|
|
}}
|
||
|
|
|
||
|
|
/* Footer */
|
||
|
|
.footer {{
|
||
|
|
text-align: center;
|
||
|
|
padding: 2rem;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
font-size: 0.8rem;
|
||
|
|
border-top: 1px solid var(--border);
|
||
|
|
margin-top: 1rem;
|
||
|
|
}}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="container">
|
||
|
|
|
||
|
|
<!-- Header -->
|
||
|
|
<div class="header">
|
||
|
|
<div>
|
||
|
|
<h1>{title}</h1>
|
||
|
|
<div class="subtitle">Generated {timestamp} | OP2: {op2_path.name}</div>
|
||
|
|
{'<div class="subtitle">Study: ' + study_name + '</div>' if study_name else ''}
|
||
|
|
</div>
|
||
|
|
<div class="branding">
|
||
|
|
<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>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 1. Executive Summary -->
|
||
|
|
<div class="section">
|
||
|
|
<h2>1. Executive Summary</h2>
|
||
|
|
<div class="exec-grid">
|
||
|
|
<div class="exec-card">
|
||
|
|
<div class="label">WFE 40\u00b0 vs 20\u00b0 (Tracking)</div>
|
||
|
|
<div class="value" style="{style_40}">{wfe_40_20:.2f} <span class="unit">nm</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="exec-card">
|
||
|
|
<div class="label">WFE 60\u00b0 vs 20\u00b0 (Tracking)</div>
|
||
|
|
<div class="value" style="{style_60}">{wfe_60_20:.2f} <span class="unit">nm</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="exec-card">
|
||
|
|
<div class="label">MFG 90\u00b0 (J1\u2212J3 Filtered)</div>
|
||
|
|
<div class="value" style="{style_mfg}">{mfg_90:.2f} <span class="unit">nm</span></div>
|
||
|
|
</div>
|
||
|
|
<div class="exec-card">
|
||
|
|
<div class="label">Weighted Sum (6\u00b7W40 + 5\u00b7W60 + 3\u00b7MFG)</div>
|
||
|
|
<div class="value" style="color: var(--accent); font-weight: 700;">{ws:.1f}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="exec-footnote">
|
||
|
|
Design targets —
|
||
|
|
WFE 40\u00b0\u221220\u00b0 \u2264 {targets['wfe_40_20']:.1f} nm |
|
||
|
|
WFE 60\u00b0\u221220\u00b0 \u2264 {targets['wfe_60_20']:.1f} nm |
|
||
|
|
MFG 90\u00b0 \u2264 {targets['mfg_90']:.1f} nm |
|
||
|
|
Weighted sum: lower is better.
|
||
|
|
{'<br>Annular aperture: inner radius = ' + f'{inner_radius:.1f} mm (\u00f8{2*inner_radius:.1f} mm central hole)' if inner_radius else ''}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 2. Per-Angle Summary -->
|
||
|
|
<div class="section">
|
||
|
|
<h2>2. Per-Angle RMS Summary</h2>
|
||
|
|
{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>
|
||
|
|
<p class="note">All values in nm. Filtered = J1\u2212J4 removed. Relative = vs 20\u00b0 reference. Aberrations are absolute.</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 3. Surface Plots -->
|
||
|
|
<div class="section">
|
||
|
|
<h2>{sec_surface}. Wavefront Error Surface Maps</h2>
|
||
|
|
<p class="note">3D residual surfaces after removing piston, tip, tilt, and defocus (J1\u2212J4). Interactive \u2014 drag to rotate.</p>
|
||
|
|
<div class="plot-grid">
|
||
|
|
<div class="plot-container">
|
||
|
|
<h3>40\u00b0 vs 20\u00b0 (Relative)</h3>
|
||
|
|
{surf_40}
|
||
|
|
</div>
|
||
|
|
<div class="plot-container">
|
||
|
|
<h3>60\u00b0 vs 20\u00b0 (Relative)</h3>
|
||
|
|
{surf_60}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="plot-container" style="margin-top:1rem">
|
||
|
|
<h3>90\u00b0 Manufacturing (Absolute)</h3>
|
||
|
|
{surf_90}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Trajectory Analysis -->
|
||
|
|
{'<div class="section"><h2>' + str(sec_traj) + '. Zernike Trajectory Analysis</h2>' +
|
||
|
|
'<p class="note">Mode-specific integrated RMS across the operating elevation range. ' +
|
||
|
|
'The linear model c<sub>j</sub>(\u03b8) = a<sub>j</sub>\u00b7\u0394sin\u03b8 + b<sub>j</sub>\u00b7\u0394cos\u03b8 decomposes gravity into axial and lateral components.</p>' +
|
||
|
|
traj_metrics_html +
|
||
|
|
'<div class="two-col" style="margin-top:1rem">' +
|
||
|
|
'<div class="plot-container"><h3>Mode RMS vs Elevation Angle</h3>' + traj_plot_html + '</div>' +
|
||
|
|
'<div class="plot-container"><h3>Axial vs Lateral Sensitivity</h3>' + sens_plot_html + '</div>' +
|
||
|
|
'</div></div>' if traj_result else ''}
|
||
|
|
|
||
|
|
<!-- Manufacturing Analysis -->
|
||
|
|
<div class="section">
|
||
|
|
<h2>{sec_mfg}. Manufacturing Analysis (90\u00b0 Orientation)</h2>
|
||
|
|
<p class="note">
|
||
|
|
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.
|
||
|
|
</p>
|
||
|
|
{mfg_html}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Design Parameters -->
|
||
|
|
{params_html}
|
||
|
|
|
||
|
|
<!-- Zernike Coefficient Details -->
|
||
|
|
<div class="section">
|
||
|
|
<h2>{sec_zernike}. Zernike Coefficient Details</h2>
|
||
|
|
<details>
|
||
|
|
<summary>40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
|
||
|
|
<div>{sfm_40}{bar_40}</div>
|
||
|
|
</details>
|
||
|
|
<details>
|
||
|
|
<summary>60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
|
||
|
|
<div>{sfm_60}{bar_60}</div>
|
||
|
|
</details>
|
||
|
|
<details>
|
||
|
|
<summary>90\u00b0 \u2014 Absolute Coefficients</summary>
|
||
|
|
<div>{sfm_90}{bar_90}</div>
|
||
|
|
</details>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Methodology -->
|
||
|
|
<div class="section">
|
||
|
|
<h2>{sec_method}. Methodology</h2>
|
||
|
|
<table class="data-table">
|
||
|
|
<tbody>
|
||
|
|
<tr><td><b>Zernike Modes</b></td><td>{N_MODES} (Noll convention)</td></tr>
|
||
|
|
<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:.0e}\u00d7)</td></tr>
|
||
|
|
<tr><td><b>Aperture</b></td><td>{'Annular (inner R = ' + f'{inner_radius:.1f} mm)' if inner_radius else 'Full disk'}</td></tr>
|
||
|
|
<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 ''}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Footer -->
|
||
|
|
<div class="footer">
|
||
|
|
Generated by <b>Atomizer</b> Optical Report Generator | {timestamp}<br>
|
||
|
|
\u00a9 Atomaste | atomaste.ca
|
||
|
|
</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"\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()
|