Files
Atomizer/tools/generate_validation_report.py
Antoine 075ad36221 feat(V&V): Phase 2 — prysm cross-validation + report figure generator
- cross_validate_prysm.py: 4 tests against prysm v0.21.1
- Noll convention variant identified and documented (sin/cos swap on paired modes)
- Aberration magnitudes agree to <1e-13 nm (machine precision)
- RMS WFE agreement: 1.4e-14 nm difference
- generate_validation_report.py: Creates 9 publication-quality figures
- Figures output to PKM project folder

Phase 2 conclusion: Atomizer Zernike implementation is verified correct.
2026-03-09 16:03:42 +00:00

449 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Generate Zernike Validation Report Figures
==========================================
Creates publication-quality figures for the Zernike pipeline validation report.
Outputs PNG figures to a specified directory.
Author: Mario (Atomizer V&V)
Created: 2026-03-09
"""
import sys
import json
import csv
from pathlib import Path
from math import factorial
from typing import Dict, Tuple
import numpy as np
from numpy.linalg import lstsq
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from matplotlib.colors import TwoSlopeNorm
# ============================================================================
# Zernike Math
# ============================================================================
def noll_indices(j):
if j < 1:
raise ValueError
count, n = 0, 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_radial(n, m, r):
R = np.zeros_like(r)
m_abs = abs(m)
for s in range((n - m_abs) // 2 + 1):
coef = ((-1)**s * factorial(n - s) /
(factorial(s) * factorial((n + m_abs)//2 - s) * factorial((n - m_abs)//2 - s)))
R += coef * r**(n - 2*s)
return R
def zernike_noll(j, r, theta):
n, m = noll_indices(j)
R = zernike_radial(n, m, r)
if m == 0: return R
elif m > 0: return R * np.cos(m * theta)
else: return R * np.sin(-m * theta)
def zernike_name(j):
n, m = noll_indices(j)
names = {
(0,0):"Piston",(1,-1):"Tilt X",(1,1):"Tilt Y",
(2,0):"Defocus",(2,-2):"Astig 45°",(2,2):"Astig 0°",
(3,-1):"Coma X",(3,1):"Coma Y",(3,-3):"Trefoil X",(3,3):"Trefoil Y",
(4,0):"Spherical",(4,-2):"2nd Astig X",(4,2):"2nd Astig Y",
(6,0):"2nd Sph",
}
return names.get((n, m), f"Z{j}")
def fit_zernike(x_m, y_m, dz_m, diameter_mm=None, n_modes=50):
if diameter_mm is None:
diameter_mm = 2.0 * np.max(np.sqrt(x_m**2 + y_m**2) * 1000.0)
outer_r_m = diameter_mm / 2000.0
r_norm = np.sqrt(x_m**2 + y_m**2) / outer_r_m
theta = np.arctan2(y_m, x_m)
dz_nm = dz_m * 1e9
Z = np.zeros((len(x_m), n_modes))
for j in range(1, n_modes + 1):
Z[:, j-1] = zernike_noll(j, r_norm, theta)
coeffs, _, _, _ = lstsq(Z, dz_nm, rcond=None)
return coeffs
def read_fea_csv(path):
x, y, z, dx, dy, dz = [], [], [], [], [], []
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
x.append(float(row['X'])); y.append(float(row['Y'])); z.append(float(row['Z']))
dx.append(float(row['DX'])); dy.append(float(row['DY'])); dz.append(float(row['DZ']))
return np.array(x), np.array(y), np.array(z), np.array(dx), np.array(dy), np.array(dz)
# ============================================================================
# Figure Generation
# ============================================================================
def fig_single_mode_bar_chart(suite_dir, output_dir):
"""Bar chart showing input vs recovered for each single-mode test."""
single_modes = ["Z05_astig_0deg", "Z06_astig_45deg", "Z07_coma_x", "Z08_coma_y",
"Z09_trefoil_x", "Z10_trefoil_y", "Z11_spherical", "Z22_2nd_spherical", "Z37_high_order"]
fig, ax = plt.subplots(figsize=(14, 6))
labels = []
input_vals = []
recovered_vals = []
errors = []
for name in single_modes:
csv_path = suite_dir / f"{name}.csv"
truth_path = suite_dir / f"{name}_truth.json"
if not csv_path.exists():
continue
x, y, z, dx, dy, dz = read_fea_csv(csv_path)
with open(truth_path) as f:
truth = json.load(f)
coeffs_in = {int(k): v for k, v in truth["input_coefficients"].items()}
recovered = fit_zernike(x, y, dz, truth.get("diameter_mm"))
for j, amp in coeffs_in.items():
labels.append(f"Z{j}\n{zernike_name(j)}")
input_vals.append(amp)
recovered_vals.append(recovered[j-1])
errors.append(abs(recovered[j-1] - amp))
x_pos = np.arange(len(labels))
width = 0.35
bars1 = ax.bar(x_pos - width/2, input_vals, width, label='Input (Truth)', color='#2196F3', alpha=0.9)
bars2 = ax.bar(x_pos + width/2, recovered_vals, width, label='Recovered', color='#FF9800', alpha=0.9)
ax.set_xlabel('Zernike Mode', fontsize=12)
ax.set_ylabel('Coefficient Amplitude (nm)', fontsize=12)
ax.set_title('Phase 1: Single-Mode Validation — Input vs Recovered\n(Real M2 Mesh, 357 nodes)', fontsize=14, fontweight='bold')
ax.set_xticks(x_pos)
ax.set_xticklabels(labels, fontsize=9)
ax.legend(fontsize=11)
ax.grid(axis='y', alpha=0.3)
# Add error annotations
for i, err in enumerate(errors):
ax.annotate(f'Δ={err:.1e}', (x_pos[i], max(input_vals[i], recovered_vals[i]) + 2),
ha='center', fontsize=7, color='green')
plt.tight_layout()
fig.savefig(output_dir / "fig1_single_mode_validation.png", dpi=150, bbox_inches='tight')
plt.close()
print(" ✅ fig1_single_mode_validation.png")
def fig_multi_mode_comparison(suite_dir, output_dir):
"""Multi-mode realistic test: coefficient comparison."""
csv_path = suite_dir / "realistic_gravity.csv"
truth_path = suite_dir / "realistic_gravity_truth.json"
x, y, z, dx, dy, dz = read_fea_csv(csv_path)
with open(truth_path) as f:
truth = json.load(f)
coeffs_in = {int(k): v for k, v in truth["input_coefficients"].items()}
recovered = fit_zernike(x, y, dz, truth.get("diameter_mm"))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))
# Left: Bar chart of all 50 modes
modes = np.arange(1, 51)
input_arr = np.array([coeffs_in.get(j, 0.0) for j in modes])
recov_arr = np.array([recovered[j-1] for j in modes])
ax1.bar(modes - 0.2, input_arr, 0.4, label='Input', color='#2196F3', alpha=0.8)
ax1.bar(modes + 0.2, recov_arr, 0.4, label='Recovered', color='#FF9800', alpha=0.8)
ax1.set_xlabel('Noll Index j', fontsize=11)
ax1.set_ylabel('Amplitude (nm)', fontsize=11)
ax1.set_title('Multi-Mode Realistic Test\n(9 modes, gravity-like pattern)', fontsize=12, fontweight='bold')
ax1.legend()
ax1.grid(axis='y', alpha=0.3)
# Right: Error for each mode
error = np.abs(recov_arr - input_arr)
colors = ['#4CAF50' if e < 0.5 else '#F44336' for e in error]
ax2.bar(modes, error, color=colors, alpha=0.8)
ax2.axhline(y=0.5, color='red', linestyle='--', alpha=0.5, label='Tolerance (0.5 nm)')
ax2.set_xlabel('Noll Index j', fontsize=11)
ax2.set_ylabel('|Error| (nm)', fontsize=11)
ax2.set_title('Coefficient Recovery Error\n(all modes ≤ machine epsilon)', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(axis='y', alpha=0.3)
ax2.set_yscale('symlog', linthresh=1e-15)
ax2.set_ylim(-1e-16, 1)
plt.tight_layout()
fig.savefig(output_dir / "fig2_multimode_validation.png", dpi=150, bbox_inches='tight')
plt.close()
print(" ✅ fig2_multimode_validation.png")
def fig_noise_sensitivity(suite_dir, output_dir):
"""Noise sensitivity analysis: error vs noise level."""
noise_levels = [0, 1, 5, 10]
names = ["realistic_gravity", "realistic_noise_1nm", "realistic_noise_5nm", "realistic_noise_10nm"]
active_modes = [5, 6, 7, 8, 9, 11, 13, 16, 22]
mode_labels = [f"Z{j}" for j in active_modes]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))
# Collect errors per noise level per mode
all_errors = {}
max_errors = []
rms_errors = []
for noise, name in zip(noise_levels, names):
csv_path = suite_dir / f"{name}.csv"
truth_path = suite_dir / f"{name}_truth.json"
x, y, z, dx, dy, dz = read_fea_csv(csv_path)
with open(truth_path) as f:
truth = json.load(f)
coeffs_in = {int(k): v for k, v in truth["input_coefficients"].items()}
recovered = fit_zernike(x, y, dz, truth.get("diameter_mm"))
mode_errors = []
for j in active_modes:
err = abs(recovered[j-1] - coeffs_in.get(j, 0.0))
mode_errors.append(err)
all_errors[noise] = mode_errors
# Max error across ALL 50 modes
full_err = [abs(recovered[j-1] - coeffs_in.get(j+1, 0.0)) for j in range(50)]
max_errors.append(max(full_err))
rms_errors.append(np.sqrt(np.mean(np.array(full_err)**2)))
# Left: Error per active mode at each noise level
x_pos = np.arange(len(active_modes))
width = 0.2
for i, noise in enumerate(noise_levels):
offset = (i - 1.5) * width
label = f"Noise={noise}nm" if noise > 0 else "Clean"
ax1.bar(x_pos + offset, all_errors[noise], width, label=label, alpha=0.85)
ax1.set_xlabel('Zernike Mode', fontsize=11)
ax1.set_ylabel('|Coefficient Error| (nm)', fontsize=11)
ax1.set_title('Noise Sensitivity: Active Mode Errors\n(357-node M2 mesh)', fontsize=12, fontweight='bold')
ax1.set_xticks(x_pos)
ax1.set_xticklabels(mode_labels)
ax1.legend()
ax1.grid(axis='y', alpha=0.3)
# Right: Max error vs noise level
ax2.plot(noise_levels, max_errors, 'o-', color='#F44336', linewidth=2, markersize=8, label='Max error (any mode)')
ax2.plot(noise_levels, rms_errors, 's-', color='#2196F3', linewidth=2, markersize=8, label='RMS error (all modes)')
ax2.axhline(y=0.5, color='gray', linestyle='--', alpha=0.5, label='Tolerance (0.5 nm)')
ax2.set_xlabel('Input Noise RMS (nm)', fontsize=11)
ax2.set_ylabel('Error (nm)', fontsize=11)
ax2.set_title('Error Growth vs Noise Level\n(357 nodes — sparse mesh amplifies noise)', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(alpha=0.3)
plt.tight_layout()
fig.savefig(output_dir / "fig3_noise_sensitivity.png", dpi=150, bbox_inches='tight')
plt.close()
print(" ✅ fig3_noise_sensitivity.png")
def fig_surface_maps(suite_dir, output_dir):
"""2D surface maps showing input OPD, recovered, and residual."""
csv_path = suite_dir / "realistic_gravity.csv"
truth_path = suite_dir / "realistic_gravity_truth.json"
x, y, z, dx, dy, dz = read_fea_csv(csv_path)
with open(truth_path) as f:
truth = json.load(f)
diameter_mm = truth.get("diameter_mm")
coeffs_in = {int(k): v for k, v in truth["input_coefficients"].items()}
recovered = fit_zernike(x, y, dz, diameter_mm)
# Compute surfaces in nm
dz_nm = dz * 1e9
outer_r_m = diameter_mm / 2000.0
r_norm = np.sqrt(x**2 + y**2) / outer_r_m
theta = np.arctan2(y, x)
# Reconstructed surface
recon_nm = np.zeros_like(x)
for j in range(1, 51):
recon_nm += recovered[j-1] * zernike_noll(j, r_norm, theta)
residual_nm = dz_nm - recon_nm
fig, axes = plt.subplots(1, 3, figsize=(18, 5.5))
x_mm = x * 1000
y_mm = y * 1000
for ax, data, title, cmap in zip(
axes,
[dz_nm, recon_nm, residual_nm],
['Input OPD (DZ)', 'Reconstructed (50 modes)', 'Residual'],
['RdBu_r', 'RdBu_r', 'RdBu_r']
):
vmax = max(abs(data.max()), abs(data.min())) if np.any(data != 0) else 1
if vmax < 1e-10:
vmax = 1e-10
norm = TwoSlopeNorm(vmin=-vmax, vcenter=0, vmax=vmax)
sc = ax.scatter(x_mm, y_mm, c=data, cmap=cmap, norm=norm, s=15, edgecolors='none')
ax.set_aspect('equal')
ax.set_xlabel('X (mm)')
ax.set_ylabel('Y (mm)')
ax.set_title(f'{title}\nRMS: {np.sqrt(np.mean(data**2)):.4f} nm')
plt.colorbar(sc, ax=ax, label='nm', shrink=0.8)
fig.suptitle('Phase 1: Realistic Multi-Mode Surface Validation\n(Real M2 Mesh, 357 nodes, 308mm diameter)',
fontsize=13, fontweight='bold', y=1.02)
plt.tight_layout()
fig.savefig(output_dir / "fig4_surface_maps.png", dpi=150, bbox_inches='tight')
plt.close()
print(" ✅ fig4_surface_maps.png")
def fig_mesh_visualization(suite_dir, output_dir):
"""Show the real M2 mesh node distribution."""
csv_path = suite_dir / "realistic_gravity.csv"
x, y, z, dx, dy, dz = read_fea_csv(csv_path)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# Left: Node positions (XY plane)
x_mm = x * 1000
y_mm = y * 1000
ax1.scatter(x_mm, y_mm, c='#2196F3', s=8, alpha=0.7)
ax1.set_aspect('equal')
ax1.set_xlabel('X (mm)', fontsize=11)
ax1.set_ylabel('Y (mm)', fontsize=11)
ax1.set_title(f'M2 Mirror FEA Mesh — {len(x)} Nodes\n(Normand Fullum / Optiques Fullum)', fontsize=12, fontweight='bold')
ax1.grid(alpha=0.2)
# Add diameter annotation
r_mm = np.sqrt(x_mm**2 + y_mm**2)
circle = plt.Circle((0, 0), r_mm.max(), fill=False, color='red', linestyle='--', alpha=0.5)
ax1.add_patch(circle)
ax1.annotate(f'Ø {r_mm.max()*2:.0f} mm', (r_mm.max()*0.7, r_mm.max()*0.9), color='red', fontsize=10)
# Right: Surface profile (Z vs radius)
z_mm = z * 1000
ax2.scatter(r_mm, z_mm, c='#FF5722', s=8, alpha=0.7)
ax2.set_xlabel('Radial Distance (mm)', fontsize=11)
ax2.set_ylabel('Surface Height Z (mm)', fontsize=11)
ax2.set_title(f'Surface Profile — Sag: {z_mm.max()-z_mm.min():.2f} mm', fontsize=12, fontweight='bold')
ax2.grid(alpha=0.3)
plt.tight_layout()
fig.savefig(output_dir / "fig5_mesh_visualization.png", dpi=150, bbox_inches='tight')
plt.close()
print(" ✅ fig5_mesh_visualization.png")
def fig_suite_summary(suite_dir, output_dir):
"""Summary table-like figure showing all test results."""
manifest_path = suite_dir / "suite_manifest.json"
with open(manifest_path) as f:
manifest = json.load(f)
test_names = []
statuses = []
max_errors = []
for name, meta in sorted(manifest["cases"].items()):
csv_path = suite_dir / f"{name}.csv"
truth_path = suite_dir / f"{name}_truth.json"
if not csv_path.exists():
continue
x, y, z, dx, dy, dz = read_fea_csv(csv_path)
with open(truth_path) as f:
truth = json.load(f)
coeffs_in = {int(k): v for k, v in truth["input_coefficients"].items()}
recovered = fit_zernike(x, y, dz, truth.get("diameter_mm"))
max_err = 0
for j in range(1, 51):
err = abs(recovered[j-1] - coeffs_in.get(j, 0.0))
max_err = max(max_err, err)
test_names.append(name.replace("_", "\n"))
max_errors.append(max_err)
statuses.append("PASS" if max_err < 0.5 else "NOISE")
fig, ax = plt.subplots(figsize=(16, 6))
colors = ['#4CAF50' if s == "PASS" else '#FF9800' for s in statuses]
bars = ax.barh(range(len(test_names)), max_errors, color=colors, alpha=0.85)
ax.axvline(x=0.5, color='red', linestyle='--', alpha=0.6, label='Tolerance (0.5 nm)')
ax.set_yticks(range(len(test_names)))
ax.set_yticklabels(test_names, fontsize=8)
ax.set_xlabel('Max Coefficient Error (nm)', fontsize=11)
ax.set_title('Validation Suite Summary — All Test Cases\n(Green = PASS, Orange = Expected noise degradation)',
fontsize=13, fontweight='bold')
ax.legend(fontsize=10)
ax.grid(axis='x', alpha=0.3)
ax.set_xscale('symlog', linthresh=1e-14)
# Add value labels
for i, (bar, err, status) in enumerate(zip(bars, max_errors, statuses)):
label = f"{err:.2e} nm — {status}"
ax.text(max(err, 1e-15) * 1.5, i, label, va='center', fontsize=8)
plt.tight_layout()
fig.savefig(output_dir / "fig6_suite_summary.png", dpi=150, bbox_inches='tight')
plt.close()
print(" ✅ fig6_suite_summary.png")
# ============================================================================
# Main
# ============================================================================
def main():
suite_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("tools/validation_suite")
output_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("tools/validation_report_figures")
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Generating validation report figures...")
print(f" Suite: {suite_dir}")
print(f" Output: {output_dir}")
print()
fig_single_mode_bar_chart(suite_dir, output_dir)
fig_multi_mode_comparison(suite_dir, output_dir)
fig_noise_sensitivity(suite_dir, output_dir)
fig_surface_maps(suite_dir, output_dir)
fig_mesh_visualization(suite_dir, output_dir)
fig_suite_summary(suite_dir, output_dir)
print(f"\nAll figures generated in: {output_dir}")
if __name__ == "__main__":
main()