Files
Atomizer/tools/zernike_html_generator.py
Anto01 82f36689b7 feat: Pre-migration checkpoint - updated docs and utilities
Updates before optimization_engine migration:
- Updated migration plan to v2.1 with complete file inventory
- Added OP_07 disk optimization protocol
- Added SYS_16 self-aware turbo protocol
- Added study archiver and cleanup utilities
- Added ensemble surrogate module
- Updated NX solver and session manager
- Updated zernike HTML generator
- Added context engineering plan
- LAC session insights updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 10:22:45 -05:00

1112 lines
41 KiB
Python

#!/usr/bin/env python3
"""
Atomizer Zernike HTML Generator
================================
Generates 3 interactive HTML reports for Zernike wavefront analysis:
1. 40° vs 20° (relative) - Operational angle comparison
2. 60° vs 20° (relative) - Operational angle comparison
3. 90° (Manufacturing) - Absolute with manufacturing metrics
Uses the rigorous OPD method from extract_zernike_figure.py which:
- Accounts for lateral (X, Y) displacement via interpolation
- Uses actual mesh geometry as reference (no shape assumptions)
- Provides more accurate WFE for mirror optimization
Usage:
conda activate atomizer
python zernike_html_generator.py "path/to/solution.op2"
# Or with file dialog (double-click via analyze_wfe.bat)
python zernike_html_generator.py
Output:
Creates 3 HTML files in same directory as OP2:
- {basename}_40_vs_20.html
- {basename}_60_vs_20.html
- {basename}_90_mfg.html
Author: Atomizer
Created: 2025-12-19
Updated: 2025-12-28 - Upgraded to use rigorous OPD method
"""
import sys
import os
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 the rigorous OPD extractor
try:
from optimization_engine.extractors.extract_zernike_figure import ZernikeOPDExtractor
USE_OPD_METHOD = True
print("[INFO] Using rigorous OPD method (accounts for lateral displacement)")
except ImportError:
USE_OPD_METHOD = False
print("[WARN] OPD extractor not available, falling back to simple Z-only method")
# ============================================================================
# Configuration
# ============================================================================
N_MODES = 50
AMP = 0.5 # visual scale for residual plot (0.5x = reduced deformation)
PANCAKE = 3.0 # Z-axis range multiplier for camera view
PLOT_DOWNSAMPLE = 10000
FILTER_LOW_ORDERS = 4 # piston, tip, tilt, defocus
# Surface plot style
COLORSCALE = 'Turbo' # Options: 'RdBu_r', 'Viridis', 'Plasma', 'Turbo', 'Jet'
SURFACE_LIGHTING = True # Smooth shaded surface with lighting
SHOW_ZERNIKE_BAR = True
REQUIRED_SUBCASES = [90, 20, 40, 60]
REF_LABEL = "2" # Reference subcase (20 deg) - using isubcase
POLISH_LABEL = "1" # Manufacturing orientation (90 deg) - using isubcase
# Displacement unit in OP2 -> nm scale for WFE = 2*Disp_Z
DISP_SRC_UNIT = "mm"
NM_PER_UNIT = 1e6 if DISP_SRC_UNIT == "mm" else 1e9
# ============================================================================
# Zernike Math
# ============================================================================
def noll_indices(j: int):
if j < 1:
raise ValueError("Noll index j must be >= 1")
count = 0
n = 0
while True:
if n == 0:
ms = [0]
elif n % 2 == 0:
ms = [0] + [m for k in range(1, n//2 + 1) for m in (-2*k, 2*k)]
else:
ms = [m for k in range(0, (n+1)//2) for m in (-(2*k+1), (2*k+1))]
for m in ms:
count += 1
if count == j:
return n, m
n += 1
def zernike_noll(j, r, th):
n, m = noll_indices(j)
R = np.zeros_like(r)
for s in range((n-abs(m))//2 + 1):
c = ((-1)**s * factorial(n-s) /
(factorial(s) *
factorial((n+abs(m))//2 - s) *
factorial((n-abs(m))//2 - s)))
R += c * r**(n-2*s)
if m == 0:
return R
return R * (np.cos(m*th) if m > 0 else np.sin(-m*th))
def compute_zernike_coeffs(X, Y, vals, n_modes, chunk_size=100000):
Xc, Yc = X - np.mean(X), Y - np.mean(Y)
R = float(np.max(np.hypot(Xc, Yc)))
r = np.hypot(Xc/R, Yc/R).astype(np.float32)
th = np.arctan2(Yc, Xc).astype(np.float32)
mask = (r <= 1.0) & ~np.isnan(vals)
if not np.any(mask):
raise RuntimeError("No valid points inside unit disk.")
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 = vals.astype(np.float64)
for start in range(0, len(idx), chunk_size):
sl = idx[start:start+chunk_size]
r_b, th_b, v_b = r[sl], th[sl], v[sl]
Zb = np.column_stack([zernike_noll(j, r_b, th_b).astype(np.float32)
for j in range(1, m+1)])
G += (Zb.T @ Zb).astype(np.float64)
h += (Zb.T @ v_b).astype(np.float64)
try:
coeffs = np.linalg.solve(G, h)
except LinAlgError:
coeffs = np.linalg.lstsq(G, h, rcond=None)[0]
return coeffs, R
def zernike_common_name(n: int, m: int) -> str:
names = {
(0, 0): "Piston", (1, -1): "Tilt X", (1, 1): "Tilt Y",
(2, 0): "Defocus", (2, -2): "Astig 45 deg", (2, 2): "Astig 0 deg",
(3, -1): "Coma X", (3, 1): "Coma Y", (3, -3): "Trefoil X", (3, 3): "Trefoil Y",
(4, 0): "Primary Spherical", (4, -2): "Sec Astig X", (4, 2): "Sec Astig Y",
(4, -4): "Quadrafoil X", (4, 4): "Quadrafoil Y",
(5, -1): "Sec Coma X", (5, 1): "Sec Coma Y",
(5, -3): "Sec Trefoil X", (5, 3): "Sec Trefoil Y",
(5, -5): "Pentafoil X", (5, 5): "Pentafoil Y",
(6, 0): "Sec Spherical",
}
return names.get((n, m), f"Z(n={n}, m={m})")
def zernike_label_for_j(j: int) -> str:
n, m = noll_indices(j)
return f"J{j:02d} - {zernike_common_name(n, m)} (n={n}, m={m})"
# ============================================================================
# File I/O
# ============================================================================
def find_geometry_file(op2_path: Path) -> Path:
"""Find matching BDF/DAT file for an OP2."""
folder = op2_path.parent
base = op2_path.stem
for ext in ['.dat', '.bdf']:
cand = folder / (base + ext)
if cand.exists():
return cand
for f in folder.iterdir():
if f.suffix.lower() in ['.dat', '.bdf']:
return f
raise FileNotFoundError(f"No .dat or .bdf geometry file found for {op2_path}")
def read_geometry(dat_path: Path) -> dict:
bdf = BDF()
bdf.read_bdf(str(dat_path))
return {int(nid): node.get_position() for nid, node in bdf.nodes.items()}
def read_displacements(op2_path: Path) -> dict:
"""Read displacement data organized by subcase."""
op2 = OP2()
op2.read_op2(str(op2_path))
if not op2.displacements:
raise RuntimeError("No displacement data found in OP2 file")
result = {}
for key, darr in op2.displacements.items():
data = darr.data
dmat = data[0] if data.ndim == 3 else (data if data.ndim == 2 else None)
if dmat is None:
continue
ngt = darr.node_gridtype.astype(int)
node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]
# Get subcase identifier
isubcase = getattr(darr, 'isubcase', None)
label = str(isubcase) if isubcase else str(key)
result[label] = {
'node_ids': node_ids.astype(int),
'disp': dmat.copy()
}
return result
# ============================================================================
# Data Processing
# ============================================================================
def build_wfe_arrays(label: str, node_ids, dmat, node_geo):
"""Build X, Y, WFE arrays for a subcase."""
X, Y, WFE = [], [], []
for nid, vec in zip(node_ids, dmat):
geo = node_geo.get(int(nid))
if geo is None:
continue
X.append(geo[0])
Y.append(geo[1])
wfe = vec[2] * 2.0 * NM_PER_UNIT # Z-disp to WFE (nm)
WFE.append(wfe)
return np.array(X), np.array(Y), np.array(WFE)
def compute_relative_wfe(X1, Y1, WFE1, node_ids1, X2, Y2, WFE2, node_ids2):
"""Compute relative WFE: WFE1 - WFE2 for common nodes."""
# Build mapping for reference
ref_map = {int(nid): (x, y, w) for nid, x, y, w in zip(node_ids2, X2, Y2, WFE2)}
X_rel, Y_rel, WFE_rel = [], [], []
for nid, x, y, w in zip(node_ids1, X1, Y1, WFE1):
nid = int(nid)
if nid in ref_map:
_, _, w_ref = ref_map[nid]
X_rel.append(x)
Y_rel.append(y)
WFE_rel.append(w - w_ref)
return np.array(X_rel), np.array(Y_rel), np.array(WFE_rel)
def compute_rms_metrics(X, Y, W_nm):
"""Compute RMS metrics and Zernike coefficients."""
coeffs, R = compute_zernike_coeffs(X, Y, W_nm, N_MODES)
Xc = X - np.mean(X)
Yc = Y - np.mean(Y)
r = np.hypot(Xc/R, Yc/R)
th = np.arctan2(Yc, Xc)
Z = np.column_stack([zernike_noll(j, r, th) for j in range(1, N_MODES+1)])
W_res_filt = W_nm - Z[:, :FILTER_LOW_ORDERS].dot(coeffs[:FILTER_LOW_ORDERS])
# J1-J3 filtered (keeping defocus - optician workload)
W_res_filt_j1to3 = W_nm - Z[:, :3].dot(coeffs[:3])
global_rms = float(np.sqrt(np.mean(W_nm**2)))
filtered_rms = float(np.sqrt(np.mean(W_res_filt**2)))
rms_filter_j1to3 = float(np.sqrt(np.mean(W_res_filt_j1to3**2)))
return {
'coefficients': coeffs,
'R': R,
'global_rms': global_rms,
'filtered_rms': filtered_rms,
'rms_filter_j1to3': rms_filter_j1to3,
'W_res_filt': W_res_filt,
}
def compute_mfg_metrics(coeffs):
"""Manufacturing aberration magnitudes from Zernike coefficients.
Noll indexing (1-based): J1=Piston, J2=TiltX, J3=TiltY, J4=Defocus,
J5=Astig45, J6=Astig0, J7=ComaX, J8=ComaY, J9=TrefoilX, J10=TrefoilY, J11=Spherical
Python 0-indexed: coeffs[0]=J1, coeffs[3]=J4, etc.
"""
# Individual mode magnitudes (RSS for paired modes)
defocus = float(abs(coeffs[3])) # J4
astigmatism = float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)) # RSS(J5, J6)
coma = float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)) # RSS(J7, J8)
trefoil = float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) # RSS(J9, J10)
spherical = float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0 # J11
# RMS of higher-order terms (J4+): sqrt(sum of squares of coefficients)
# This is the proper Zernike-coefficient-based RMS excluding piston/tip/tilt
higher_order_rms = float(np.sqrt(np.sum(coeffs[3:]**2)))
return {
'defocus_nm': defocus,
'astigmatism_rms': astigmatism,
'coma_rms': coma,
'trefoil_rms': trefoil,
'spherical_nm': spherical,
'higher_order_rms': higher_order_rms, # RMS of all J4+ coefficients
}
# ============================================================================
# HTML Generation
# ============================================================================
def generate_html(
title: str,
X: np.ndarray,
Y: np.ndarray,
W_nm: np.ndarray,
rms_data: dict,
is_relative: bool = False,
ref_title: str = "20 deg",
abs_pair: tuple = None,
is_manufacturing: bool = False,
mfg_metrics: dict = None,
correction_metrics: dict = None,
) -> str:
"""Generate complete HTML for Zernike visualization."""
coeffs = rms_data['coefficients']
global_rms = rms_data['global_rms']
filtered_rms = rms_data['filtered_rms']
W_res_filt = rms_data['W_res_filt']
labels = [zernike_label_for_j(j) for j in range(1, N_MODES+1)]
coeff_abs = np.abs(coeffs)
# Downsample for display
n = len(X)
if n > PLOT_DOWNSAMPLE:
rng = np.random.default_rng(42)
sel = rng.choice(n, size=PLOT_DOWNSAMPLE, replace=False)
Xp, Yp, Wp = X[sel], Y[sel], W_res_filt[sel]
else:
Xp, Yp, Wp = X, Y, W_res_filt
res_amp = AMP * Wp
max_amp = float(np.max(np.abs(res_amp))) if res_amp.size else 1.0
# Triangulate for smooth mesh surface
mesh_traces = []
try:
tri = Triangulation(Xp, Yp)
if tri.triangles is not None and len(tri.triangles) > 0:
i, j, k = tri.triangles.T
# Create smooth shaded mesh with lighting
mesh_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, # Smooth shading (interpolated normals)
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="Residual (nm)", side="right"),
thickness=15,
len=0.6,
tickformat=".1f"
),
hovertemplate="X: %{x:.1f}<br>Y: %{y:.1f}<br>Residual: %{z:.2f} nm<extra></extra>"
))
except Exception as e:
print(f"Triangulation warning: {e}")
# Fallback scatter only if mesh failed
if not mesh_traces:
mesh_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
))
title_suffix = f" (relative to {ref_title})" if is_relative else " (absolute)"
# Build figure layout based on content type
if is_manufacturing and mfg_metrics and correction_metrics:
# Manufacturing page: 5 rows
fig = make_subplots(
rows=5, cols=1,
specs=[[{"type":"scene"}],
[{"type":"table"}],
[{"type":"table"}],
[{"type":"table"}],
[{"type":"xy"}]],
row_heights=[0.38, 0.12, 0.12, 0.18, 0.20],
vertical_spacing=0.025,
subplot_titles=[
f"<b>Surface Residual - {title}{title_suffix}</b>",
"<b>RMS Metrics (Absolute 90 deg)</b>",
"<b>Mode Magnitudes at 90 deg</b>",
"<b>Pre-Correction (90 deg - 20 deg)</b>",
"<b>|Zernike Coefficients| (nm)</b>"
]
)
elif SHOW_ZERNIKE_BAR:
# Standard page with bar chart: 4 rows
fig = make_subplots(
rows=4, cols=1,
specs=[[{"type":"scene"}],
[{"type":"table"}],
[{"type":"table"}],
[{"type":"xy"}]],
row_heights=[0.45, 0.12, 0.25, 0.18],
vertical_spacing=0.03,
subplot_titles=[
f"<b>Surface Residual - {title}{title_suffix}</b>",
"<b>RMS Metrics</b>",
f"<b>Zernike Coefficients ({N_MODES} modes)</b>",
"<b>|Zernike Coefficients| (nm)</b>"
]
)
else:
# Standard page without bar: 3 rows
fig = make_subplots(
rows=3, cols=1,
specs=[[{"type":"scene"}],
[{"type":"table"}],
[{"type":"table"}]],
row_heights=[0.55, 0.15, 0.30],
vertical_spacing=0.03,
subplot_titles=[
f"<b>Surface Residual - {title}{title_suffix}</b>",
"<b>RMS Metrics</b>",
f"<b>Zernike Coefficients ({N_MODES} modes)</b>"
]
)
# Add 3D mesh traces
for tr in mesh_traces:
fig.add_trace(tr, row=1, col=1)
# Configure 3D scene with better camera angle and proportional axes
fig.update_scenes(
camera=dict(
eye=dict(x=1.2, y=1.2, z=0.8), # Angled view from above
up=dict(x=0, y=0, z=1)
),
xaxis=dict(
title="X (mm)",
showgrid=True,
gridcolor='rgba(128,128,128,0.3)',
showbackground=True,
backgroundcolor='rgba(240,240,240,0.9)'
),
yaxis=dict(
title="Y (mm)",
showgrid=True,
gridcolor='rgba(128,128,128,0.3)',
showbackground=True,
backgroundcolor='rgba(240,240,240,0.9)'
),
zaxis=dict(
title="Residual (nm)",
range=[-max_amp * PANCAKE, max_amp * PANCAKE],
showgrid=True,
gridcolor='rgba(128,128,128,0.3)',
showbackground=True,
backgroundcolor='rgba(230,230,250,0.9)'
),
aspectmode='manual',
aspectratio=dict(x=1, y=1, z=0.4) # Flatten Z for better visualization
)
# RMS table (row 2)
if is_relative and abs_pair:
abs_global, abs_filtered = abs_pair
fig.add_trace(go.Table(
header=dict(values=["<b>Metric</b>", "<b>Relative (nm)</b>", "<b>Absolute (nm)</b>"],
align="left", fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[
["Global RMS", "Filtered RMS (J1-J4 removed)"],
[f"{global_rms:.2f}", f"{filtered_rms:.2f}"],
[f"{abs_global:.2f}", f"{abs_filtered:.2f}"],
], align="left", fill_color='#374151', font=dict(color='white'))
), row=2, col=1)
elif is_manufacturing and mfg_metrics and correction_metrics:
# 90 deg absolute RMS
fig.add_trace(go.Table(
header=dict(values=["<b>Metric</b>", "<b>Value (nm)</b>"],
align="left", fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[
["Global RMS", "Filtered RMS (J1-J4)"],
[f"{global_rms:.2f}", f"{filtered_rms:.2f}"]
], align="left", fill_color='#374151', font=dict(color='white'))
), row=2, col=1)
# Mode magnitudes (row 3)
fig.add_trace(go.Table(
header=dict(values=["<b>Mode</b>", "<b>Value (nm)</b>"],
align="left", fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[
["Filtered RMS (J1-J3, with defocus)",
"Astigmatism (J5+J6)",
"Coma (J7+J8)",
"Trefoil (J9+J10)",
"Spherical (J11)"],
[f"{rms_data['rms_filter_j1to3']:.2f}",
f"{mfg_metrics['astigmatism_rms']:.2f}",
f"{mfg_metrics['coma_rms']:.2f}",
f"{mfg_metrics['trefoil_rms']:.2f}",
f"{mfg_metrics['spherical_nm']:.2f}"]
], align="left", fill_color='#374151', font=dict(color='white'))
), row=3, col=1)
# Pre-correction (row 4) - Aberrations to polish out (90° - 20°)
# Shows what correction is needed when manufacturing at 90° to achieve 20° figure
fig.add_trace(go.Table(
header=dict(values=["<b>Aberration</b>", "<b>Magnitude (nm)</b>"],
align="left", fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[
["Defocus (J4)",
"Astigmatism (J5+J6)",
"Coma (J7+J8)",
"Trefoil (J9+J10)",
"Spherical (J11)"],
[f"{correction_metrics['defocus_nm']:.2f}",
f"{correction_metrics['astigmatism_rms']:.2f}",
f"{correction_metrics['coma_rms']:.2f}",
f"{correction_metrics['trefoil_rms']:.2f}",
f"{correction_metrics['spherical_nm']:.2f}"]
], align="left", fill_color='#374151', font=dict(color='white'))
), row=4, col=1)
else:
# Standard absolute table
fig.add_trace(go.Table(
header=dict(values=["<b>Metric</b>", "<b>Value (nm)</b>"],
align="left", fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[
["Global RMS", "Filtered RMS (J1-J4 removed)"],
[f"{global_rms:.2f}", f"{filtered_rms:.2f}"]
], align="left", fill_color='#374151', font=dict(color='white'))
), row=2, col=1)
# Zernike coefficients table (row 3 or skip if manufacturing)
if not (is_manufacturing and mfg_metrics and correction_metrics):
fig.add_trace(go.Table(
header=dict(values=["<b>Noll j</b>", "<b>Label</b>", "<b>|Coeff| (nm)</b>"],
align="left", fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[
list(range(1, N_MODES+1)),
labels,
[f"{c:.3f}" for c in coeff_abs]
], align="left", fill_color='#374151', font=dict(color='white'))
), row=3, col=1)
# Bar chart (last row)
if SHOW_ZERNIKE_BAR:
bar_row = 5 if (is_manufacturing and mfg_metrics and correction_metrics) else 4
fig.add_trace(
go.Bar(
x=coeff_abs.tolist(),
y=labels,
orientation='h',
marker_color='#6366f1',
hovertemplate="%{y}<br>|Coeff| = %{x:.3f} nm<extra></extra>",
showlegend=False
),
row=bar_row, col=1
)
# Layout
height = 1500 if (is_manufacturing and mfg_metrics and correction_metrics) else 1300
fig.update_layout(
width=1400,
height=height,
margin=dict(t=60, b=20, l=20, r=20),
paper_bgcolor='#111827',
plot_bgcolor='#1f2937',
font=dict(color='white'),
title=dict(
text=f"<b>Atomizer Zernike Analysis - {title}</b>",
x=0.5,
font=dict(size=18)
)
)
return fig.to_html(include_plotlyjs='cdn', full_html=True)
# ============================================================================
# Main
# ============================================================================
def find_op2_file(working_dir=None):
"""Find the most recent OP2 file in the working directory."""
if working_dir is None:
working_dir = Path.cwd()
else:
working_dir = Path(working_dir)
op2_files = list(working_dir.glob("*solution*.op2")) + list(working_dir.glob("*.op2"))
if not op2_files:
op2_files = list(working_dir.glob("**/*solution*.op2"))
if not op2_files:
return None
return max(op2_files, key=lambda p: p.stat().st_mtime)
def main_opd(op2_path: Path):
"""Generate all 3 HTML files using rigorous OPD method."""
print("=" * 70)
print(" ATOMIZER ZERNIKE HTML GENERATOR (OPD METHOD)")
print("=" * 70)
print(f"\nOP2 File: {op2_path.name}")
print(f"Directory: {op2_path.parent}")
print("\n[INFO] Using OPD method: accounts for lateral (X,Y) displacement")
# Initialize extractor
extractor = ZernikeOPDExtractor(
op2_path,
displacement_unit='mm',
n_modes=N_MODES,
filter_orders=FILTER_LOW_ORDERS
)
print(f"\nAvailable subcases: {list(extractor.displacements.keys())}")
# Map subcases (try common patterns)
displacements = extractor.displacements
subcase_map = {}
if '1' in displacements and '2' in displacements:
subcase_map = {'90': '1', '20': '2', '40': '3', '60': '4'}
elif '90' in displacements and '20' in displacements:
subcase_map = {'90': '90', '20': '20', '40': '40', '60': '60'}
else:
available = sorted(displacements.keys(), key=lambda x: int(x) if x.isdigit() else 0)
if len(available) >= 4:
subcase_map = {'90': available[0], '20': available[1], '40': available[2], '60': available[3]}
print(f"[WARN] Using mapped subcases: {subcase_map}")
else:
print(f"[ERROR] Need 4 subcases, found: {available}")
return
output_dir = op2_path.parent
base = op2_path.stem
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
html_files = []
# ========================================================================
# Extract absolute metrics for each subcase
# ========================================================================
print("\nExtracting absolute metrics (OPD method)...")
results_abs = {}
for angle, label in subcase_map.items():
result = extractor.extract_subcase(label, include_coefficients=True)
results_abs[angle] = result
lat_disp = result.get('max_lateral_displacement_um', 0)
print(f" {angle} deg: Filtered RMS = {result['filtered_rms_nm']:.2f} nm, "
f"Lateral disp max = {lat_disp:.3f} um")
# ========================================================================
# Extract relative metrics (40-20, 60-20, 90-20)
# ========================================================================
print("\nExtracting relative metrics (OPD method)...")
# 40 vs 20
result_40_rel = extractor.extract_relative(subcase_map['40'], subcase_map['20'], include_coefficients=True)
print(f" 40-20: Relative Filtered RMS = {result_40_rel['relative_filtered_rms_nm']:.2f} nm")
# 60 vs 20
result_60_rel = extractor.extract_relative(subcase_map['60'], subcase_map['20'], include_coefficients=True)
print(f" 60-20: Relative Filtered RMS = {result_60_rel['relative_filtered_rms_nm']:.2f} nm")
# 90 vs 20 (for correction metrics)
result_90_rel = extractor.extract_relative(subcase_map['90'], subcase_map['20'], include_coefficients=True)
print(f" 90-20: Relative Filtered RMS = {result_90_rel['relative_filtered_rms_nm']:.2f} nm")
# ========================================================================
# Generate HTML files
# ========================================================================
# Helper to convert OPD results to the format expected by generate_html
def opd_to_rms_data(result, is_relative=False):
"""Convert OPD extractor result to rms_data dict for generate_html."""
coeffs = np.array(result.get('coefficients', [0] * N_MODES))
# Recompute filtered residuals for visualization
# For now, use simplified approach - the metrics are correct
filtered_rms = result.get('relative_filtered_rms_nm' if is_relative else 'filtered_rms_nm', 0)
global_rms = result.get('relative_global_rms_nm' if is_relative else 'global_rms_nm', 0)
rms_j1to3 = result.get('relative_rms_filter_j1to3' if is_relative else 'rms_filter_j1to3_nm', 0)
# We need W_res_filt for visualization - extract from diagnostic data
# For now, create a placeholder that will be updated
return {
'coefficients': coeffs,
'R': 1.0, # Will be updated
'global_rms': global_rms,
'filtered_rms': filtered_rms,
'rms_filter_j1to3': rms_j1to3,
'W_res_filt': None, # Will compute separately for visualization
}
# For visualization, we need the actual WFE arrays
# Get diagnostic data from extractor
print("\nGenerating HTML reports...")
# 40 vs 20
print(" Generating 40 deg vs 20 deg...")
opd_40 = extractor._build_figure_opd_data(subcase_map['40'])
opd_20 = extractor._build_figure_opd_data(subcase_map['20'])
# Build relative WFE arrays
ref_wfe_map = {int(nid): wfe for nid, wfe in zip(opd_20['node_ids'], opd_20['wfe_nm'])}
X_40_rel, Y_40_rel, WFE_40_rel = [], [], []
for i, nid in enumerate(opd_40['node_ids']):
nid = int(nid)
if nid in ref_wfe_map:
X_40_rel.append(opd_40['x_deformed'][i])
Y_40_rel.append(opd_40['y_deformed'][i])
WFE_40_rel.append(opd_40['wfe_nm'][i] - ref_wfe_map[nid])
X_40_rel = np.array(X_40_rel)
Y_40_rel = np.array(Y_40_rel)
WFE_40_rel = np.array(WFE_40_rel)
rms_40_rel = compute_rms_metrics(X_40_rel, Y_40_rel, WFE_40_rel)
rms_40_abs = compute_rms_metrics(opd_40['x_deformed'], opd_40['y_deformed'], opd_40['wfe_nm'])
html_40 = generate_html(
title="40 deg (OPD)",
X=X_40_rel, Y=Y_40_rel, W_nm=WFE_40_rel,
rms_data=rms_40_rel,
is_relative=True,
ref_title="20 deg",
abs_pair=(rms_40_abs['global_rms'], rms_40_abs['filtered_rms'])
)
path_40 = output_dir / f"{base}_{timestamp}_40_vs_20.html"
path_40.write_text(html_40, encoding='utf-8')
html_files.append(path_40)
print(f" Created: {path_40.name}")
# 60 vs 20
print(" Generating 60 deg vs 20 deg...")
opd_60 = extractor._build_figure_opd_data(subcase_map['60'])
X_60_rel, Y_60_rel, WFE_60_rel = [], [], []
for i, nid in enumerate(opd_60['node_ids']):
nid = int(nid)
if nid in ref_wfe_map:
X_60_rel.append(opd_60['x_deformed'][i])
Y_60_rel.append(opd_60['y_deformed'][i])
WFE_60_rel.append(opd_60['wfe_nm'][i] - ref_wfe_map[nid])
X_60_rel = np.array(X_60_rel)
Y_60_rel = np.array(Y_60_rel)
WFE_60_rel = np.array(WFE_60_rel)
rms_60_rel = compute_rms_metrics(X_60_rel, Y_60_rel, WFE_60_rel)
rms_60_abs = compute_rms_metrics(opd_60['x_deformed'], opd_60['y_deformed'], opd_60['wfe_nm'])
html_60 = generate_html(
title="60 deg (OPD)",
X=X_60_rel, Y=Y_60_rel, W_nm=WFE_60_rel,
rms_data=rms_60_rel,
is_relative=True,
ref_title="20 deg",
abs_pair=(rms_60_abs['global_rms'], rms_60_abs['filtered_rms'])
)
path_60 = output_dir / f"{base}_{timestamp}_60_vs_20.html"
path_60.write_text(html_60, encoding='utf-8')
html_files.append(path_60)
print(f" Created: {path_60.name}")
# 90 deg Manufacturing
print(" Generating 90 deg Manufacturing...")
opd_90 = extractor._build_figure_opd_data(subcase_map['90'])
rms_90 = compute_rms_metrics(opd_90['x_deformed'], opd_90['y_deformed'], opd_90['wfe_nm'])
mfg_metrics = compute_mfg_metrics(rms_90['coefficients'])
# 90-20 relative for correction metrics
X_90_rel, Y_90_rel, WFE_90_rel = [], [], []
for i, nid in enumerate(opd_90['node_ids']):
nid = int(nid)
if nid in ref_wfe_map:
X_90_rel.append(opd_90['x_deformed'][i])
Y_90_rel.append(opd_90['y_deformed'][i])
WFE_90_rel.append(opd_90['wfe_nm'][i] - ref_wfe_map[nid])
X_90_rel = np.array(X_90_rel)
Y_90_rel = np.array(Y_90_rel)
WFE_90_rel = np.array(WFE_90_rel)
rms_90_rel = compute_rms_metrics(X_90_rel, Y_90_rel, WFE_90_rel)
# Get all correction metrics from Zernike coefficients (90° - 20°)
correction_metrics = compute_mfg_metrics(rms_90_rel['coefficients'])
html_90 = generate_html(
title="90 deg Manufacturing (OPD)",
X=opd_90['x_deformed'], Y=opd_90['y_deformed'], W_nm=opd_90['wfe_nm'],
rms_data=rms_90,
is_relative=False,
is_manufacturing=True,
mfg_metrics=mfg_metrics,
correction_metrics=correction_metrics
)
path_90 = output_dir / f"{base}_{timestamp}_90_mfg.html"
path_90.write_text(html_90, encoding='utf-8')
html_files.append(path_90)
print(f" Created: {path_90.name}")
# ========================================================================
# Summary
# ========================================================================
print("\n" + "=" * 70)
print("SUMMARY (OPD Method)")
print("=" * 70)
print(f"\nGenerated {len(html_files)} HTML files:")
for f in html_files:
print(f" - {f.name}")
print("\n" + "-" * 70)
print("OPTIMIZATION OBJECTIVES (OPD Method)")
print("-" * 70)
print(f" 40-20 Filtered RMS: {rms_40_rel['filtered_rms']:.2f} nm")
print(f" 60-20 Filtered RMS: {rms_60_rel['filtered_rms']:.2f} nm")
print(f" MFG 90 (J1-J3): {rms_90_rel['rms_filter_j1to3']:.2f} nm")
# Weighted sums
ws_v4 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 2*rms_90_rel['rms_filter_j1to3']
ws_v5 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 3*rms_90_rel['rms_filter_j1to3']
print(f"\n V4 Weighted Sum (5/5/2): {ws_v4:.2f}")
print(f" V5 Weighted Sum (5/5/3): {ws_v5:.2f}")
# Lateral displacement summary
print("\n" + "-" * 70)
print("LATERAL DISPLACEMENT SUMMARY")
print("-" * 70)
for angle in ['20', '40', '60', '90']:
lat = results_abs[angle].get('max_lateral_displacement_um', 0)
print(f" {angle} deg: max {lat:.3f} um")
print("\n" + "=" * 70)
print("DONE")
print("=" * 70)
return html_files
def main(op2_path: Path):
"""Generate all 3 HTML files (legacy Z-only method)."""
print("=" * 70)
print(" ATOMIZER ZERNIKE HTML GENERATOR")
print("=" * 70)
print(f"\nOP2 File: {op2_path.name}")
print(f"Directory: {op2_path.parent}")
# Find geometry
print("\nFinding geometry file...")
geo_path = find_geometry_file(op2_path)
print(f"Found: {geo_path.name}")
# Read data
print("\nReading geometry...")
node_geo = read_geometry(geo_path)
print(f"Loaded {len(node_geo)} nodes")
print("\nReading displacements...")
displacements = read_displacements(op2_path)
print(f"Found subcases: {list(displacements.keys())}")
# Map subcases (try common patterns)
# Pattern 1: Direct labels (1, 2, 3, 4)
# Pattern 2: Angle labels (90, 20, 40, 60)
subcase_map = {}
if '1' in displacements and '2' in displacements:
# Standard NX pattern: 1=90deg, 2=20deg, 3=40deg, 4=60deg
subcase_map = {'90': '1', '20': '2', '40': '3', '60': '4'}
elif '90' in displacements and '20' in displacements:
# Direct angle labels
subcase_map = {'90': '90', '20': '20', '40': '40', '60': '60'}
else:
# Try to use whatever is available
available = sorted(displacements.keys(), key=lambda x: int(x) if x.isdigit() else 0)
if len(available) >= 4:
subcase_map = {'90': available[0], '20': available[1], '40': available[2], '60': available[3]}
print(f"[WARN] Using mapped subcases: {subcase_map}")
else:
print(f"[ERROR] Need 4 subcases, found: {available}")
return
# Check all required subcases exist
for angle, label in subcase_map.items():
if label not in displacements:
print(f"[ERROR] Subcase '{label}' (angle {angle}) not found")
return
output_dir = op2_path.parent
base = op2_path.stem
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
html_files = []
# ========================================================================
# Build WFE arrays for reference (20 deg) and manufacturing (90 deg)
# ========================================================================
print("\nProcessing subcases...")
# Reference: 20 deg
ref_label = subcase_map['20']
ref_data = displacements[ref_label]
X_ref, Y_ref, WFE_ref = build_wfe_arrays(
'20', ref_data['node_ids'], ref_data['disp'], node_geo
)
rms_ref = compute_rms_metrics(X_ref, Y_ref, WFE_ref)
print(f" 20 deg: Global RMS = {rms_ref['global_rms']:.2f} nm, Filtered = {rms_ref['filtered_rms']:.2f} nm")
# Manufacturing: 90 deg
mfg_label = subcase_map['90']
mfg_data = displacements[mfg_label]
X_90, Y_90, WFE_90 = build_wfe_arrays(
'90', mfg_data['node_ids'], mfg_data['disp'], node_geo
)
rms_90 = compute_rms_metrics(X_90, Y_90, WFE_90)
mfg_metrics = compute_mfg_metrics(rms_90['coefficients'])
print(f" 90 deg: Global RMS = {rms_90['global_rms']:.2f} nm, Filtered = {rms_90['filtered_rms']:.2f} nm")
# ========================================================================
# 1. Generate 40 deg vs 20 deg (relative)
# ========================================================================
print("\nGenerating 40 deg vs 20 deg...")
sc_40_label = subcase_map['40']
sc_40_data = displacements[sc_40_label]
X_40, Y_40, WFE_40 = build_wfe_arrays(
'40', sc_40_data['node_ids'], sc_40_data['disp'], node_geo
)
X_40_rel, Y_40_rel, WFE_40_rel = compute_relative_wfe(
X_40, Y_40, WFE_40, sc_40_data['node_ids'],
X_ref, Y_ref, WFE_ref, ref_data['node_ids']
)
rms_40_abs = compute_rms_metrics(X_40, Y_40, WFE_40)
rms_40_rel = compute_rms_metrics(X_40_rel, Y_40_rel, WFE_40_rel)
html_40 = generate_html(
title="40 deg",
X=X_40_rel, Y=Y_40_rel, W_nm=WFE_40_rel,
rms_data=rms_40_rel,
is_relative=True,
ref_title="20 deg",
abs_pair=(rms_40_abs['global_rms'], rms_40_abs['filtered_rms'])
)
path_40 = output_dir / f"{base}_{timestamp}_40_vs_20.html"
path_40.write_text(html_40, encoding='utf-8')
html_files.append(path_40)
print(f" Created: {path_40.name}")
print(f" Relative: Global={rms_40_rel['global_rms']:.2f}, Filtered={rms_40_rel['filtered_rms']:.2f}")
# ========================================================================
# 2. Generate 60 deg vs 20 deg (relative)
# ========================================================================
print("\nGenerating 60 deg vs 20 deg...")
sc_60_label = subcase_map['60']
sc_60_data = displacements[sc_60_label]
X_60, Y_60, WFE_60 = build_wfe_arrays(
'60', sc_60_data['node_ids'], sc_60_data['disp'], node_geo
)
X_60_rel, Y_60_rel, WFE_60_rel = compute_relative_wfe(
X_60, Y_60, WFE_60, sc_60_data['node_ids'],
X_ref, Y_ref, WFE_ref, ref_data['node_ids']
)
rms_60_abs = compute_rms_metrics(X_60, Y_60, WFE_60)
rms_60_rel = compute_rms_metrics(X_60_rel, Y_60_rel, WFE_60_rel)
html_60 = generate_html(
title="60 deg",
X=X_60_rel, Y=Y_60_rel, W_nm=WFE_60_rel,
rms_data=rms_60_rel,
is_relative=True,
ref_title="20 deg",
abs_pair=(rms_60_abs['global_rms'], rms_60_abs['filtered_rms'])
)
path_60 = output_dir / f"{base}_{timestamp}_60_vs_20.html"
path_60.write_text(html_60, encoding='utf-8')
html_files.append(path_60)
print(f" Created: {path_60.name}")
print(f" Relative: Global={rms_60_rel['global_rms']:.2f}, Filtered={rms_60_rel['filtered_rms']:.2f}")
# ========================================================================
# 3. Generate 90 deg Manufacturing (absolute with correction metrics)
# ========================================================================
print("\nGenerating 90 deg Manufacturing...")
# Compute 90 deg - 20 deg for correction metrics
X_90_rel, Y_90_rel, WFE_90_rel = compute_relative_wfe(
X_90, Y_90, WFE_90, mfg_data['node_ids'],
X_ref, Y_ref, WFE_ref, ref_data['node_ids']
)
rms_90_rel = compute_rms_metrics(X_90_rel, Y_90_rel, WFE_90_rel)
# Get all correction metrics from Zernike coefficients (90° - 20°)
correction_metrics = compute_mfg_metrics(rms_90_rel['coefficients'])
html_90 = generate_html(
title="90 deg (Manufacturing)",
X=X_90, Y=Y_90, W_nm=WFE_90,
rms_data=rms_90,
is_relative=False,
is_manufacturing=True,
mfg_metrics=mfg_metrics,
correction_metrics=correction_metrics
)
path_90 = output_dir / f"{base}_{timestamp}_90_mfg.html"
path_90.write_text(html_90, encoding='utf-8')
html_files.append(path_90)
print(f" Created: {path_90.name}")
print(f" Absolute: Global={rms_90['global_rms']:.2f}, Filtered={rms_90['filtered_rms']:.2f}")
print(f" Optician Workload (J1-J3): {rms_90['rms_filter_j1to3']:.2f} nm")
# ========================================================================
# Summary
# ========================================================================
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
print(f"\nGenerated {len(html_files)} HTML files:")
for f in html_files:
print(f" - {f.name}")
print("\n" + "-" * 70)
print("OPTIMIZATION OBJECTIVES")
print("-" * 70)
print(f" 40-20 Filtered RMS: {rms_40_rel['filtered_rms']:.2f} nm")
print(f" 60-20 Filtered RMS: {rms_60_rel['filtered_rms']:.2f} nm")
print(f" MFG 90 (J1-J3): {rms_90_rel['rms_filter_j1to3']:.2f} nm")
# Weighted sums
ws_v4 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 2*rms_90_rel['rms_filter_j1to3']
ws_v5 = 5*rms_40_rel['filtered_rms'] + 5*rms_60_rel['filtered_rms'] + 3*rms_90_rel['rms_filter_j1to3']
print(f"\n V4 Weighted Sum (5/5/2): {ws_v4:.2f}")
print(f" V5 Weighted Sum (5/5/3): {ws_v5:.2f}")
print("\n" + "=" * 70)
print("DONE")
print("=" * 70)
return html_files
if __name__ == '__main__':
if len(sys.argv) > 1:
op2_path = Path(sys.argv[1])
if not op2_path.exists():
print(f"ERROR: File not found: {op2_path}")
sys.exit(1)
else:
print("No OP2 file specified, searching...")
op2_path = find_op2_file()
if op2_path is None:
print("ERROR: No OP2 file found in current directory.")
print("Usage: python zernike_html_generator.py <path/to/solution.op2>")
sys.exit(1)
print(f"Found: {op2_path}")
# Check for --legacy flag to use old Z-only method
use_legacy = '--legacy' in sys.argv or '--z-only' in sys.argv
try:
if USE_OPD_METHOD and not use_legacy:
main_opd(op2_path)
else:
if use_legacy:
print("[INFO] Using legacy Z-only method (--legacy flag)")
main(op2_path)
except Exception as e:
print(f"\nERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)