2026-03-09 15:49:06 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Synthetic WFE Surface Generator for Zernike Pipeline Validation
|
|
|
|
|
================================================================
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
Generates synthetic FEA-style CSV files with known Zernike content for
|
|
|
|
|
validating the WFE_from_CSV_OPD tool. Output matches the exact format
|
|
|
|
|
used by Atomizer's Zernike analysis pipeline.
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
Output CSV format (matching WFE_from_CSV_OPD):
|
|
|
|
|
,X,Y,Z,DX,DY,DZ
|
|
|
|
|
0, x_meters, y_meters, z_meters, dx_meters, dy_meters, dz_meters
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
Where:
|
|
|
|
|
X, Y, Z = undeformed node positions (meters)
|
|
|
|
|
DX, DY, DZ = displacement vector (meters)
|
|
|
|
|
The tool computes OPD from displacements (rigorous method accounts for
|
|
|
|
|
lateral DX/DY via interpolation on the reference surface).
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
Two operating modes:
|
|
|
|
|
1. PURE SYNTHETIC: Generate a flat/spherical mirror mesh + inject known
|
|
|
|
|
Zernike displacements as DZ. Ground truth is exact.
|
|
|
|
|
2. INJECT INTO REAL: Load a real FEA CSV, zero out DZ, then inject known
|
|
|
|
|
Zernike content. Tests the full pipeline including real mesh geometry.
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
Usage:
|
|
|
|
|
# Pure synthetic (flat mirror, M1 params)
|
|
|
|
|
python generate_synthetic_wfe.py --zernike "5:100,7:50" -o test.csv
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Pure synthetic with spherical surface
|
|
|
|
|
python generate_synthetic_wfe.py --preset realistic --surface sphere --roc 5000 -o test.csv
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Inject into real M2 data
|
|
|
|
|
python generate_synthetic_wfe.py --inject m2_data.csv --zernike "5:100" -o test_injected.csv
|
|
|
|
|
|
|
|
|
|
# Full validation suite
|
|
|
|
|
python generate_synthetic_wfe.py --suite -d validation_suite/
|
|
|
|
|
|
|
|
|
|
# Full suite with injection into real mesh
|
|
|
|
|
python generate_synthetic_wfe.py --suite --inject m2_data.csv -d validation_suite/
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
Author: Mario (Atomizer V&V)
|
|
|
|
|
Created: 2026-03-09
|
|
|
|
|
Project: P-Zernike-Validation (GigaBIT M1)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import os
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
2026-03-09 15:56:23 +00:00
|
|
|
import csv
|
2026-03-09 15:49:06 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
from math import factorial
|
|
|
|
|
from typing import Dict, List, Tuple, Optional
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Zernike Polynomial Mathematics (matching extract_zernike.py conventions)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def noll_indices(j: int) -> Tuple[int, int]:
|
|
|
|
|
"""Convert Noll index j to radial order n and azimuthal frequency m."""
|
|
|
|
|
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_radial(n: int, m: int, r: np.ndarray) -> np.ndarray:
|
|
|
|
|
"""Compute radial component R_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: int, r: np.ndarray, theta: np.ndarray) -> np.ndarray:
|
|
|
|
|
"""Evaluate Noll-indexed Zernike polynomial Z_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: int) -> str:
|
|
|
|
|
"""Get common optical name for Zernike mode."""
|
|
|
|
|
n, m = noll_indices(j)
|
|
|
|
|
names = {
|
|
|
|
|
(0, 0): "Piston",
|
|
|
|
|
(1, -1): "Tilt X", (1, 1): "Tilt Y",
|
|
|
|
|
(2, 0): "Defocus",
|
|
|
|
|
(2, -2): "Astigmatism 45°", (2, 2): "Astigmatism 0°",
|
|
|
|
|
(3, -1): "Coma X", (3, 1): "Coma Y",
|
|
|
|
|
(3, -3): "Trefoil X", (3, 3): "Trefoil Y",
|
|
|
|
|
(4, 0): "Primary Spherical",
|
|
|
|
|
(4, -2): "2nd Astig X", (4, 2): "2nd Astig Y",
|
|
|
|
|
(4, -4): "Quadrafoil X", (4, 4): "Quadrafoil Y",
|
|
|
|
|
(5, -1): "2nd Coma X", (5, 1): "2nd Coma Y",
|
|
|
|
|
(5, -3): "2nd Trefoil X", (5, 3): "2nd Trefoil Y",
|
|
|
|
|
(5, -5): "Pentafoil X", (5, 5): "Pentafoil Y",
|
|
|
|
|
(6, 0): "2nd Spherical",
|
|
|
|
|
}
|
|
|
|
|
if (n, m) in names:
|
|
|
|
|
return names[(n, m)]
|
|
|
|
|
return f"Z({n},{m:+d})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
2026-03-09 15:56:23 +00:00
|
|
|
# Mesh Generation
|
2026-03-09 15:49:06 +00:00
|
|
|
# ============================================================================
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
def generate_mesh(
|
2026-03-09 15:49:06 +00:00
|
|
|
diameter_mm: float = 1200.0,
|
2026-03-09 15:56:23 +00:00
|
|
|
inner_radius_mm: float = 0.0,
|
|
|
|
|
n_rings: int = 20,
|
|
|
|
|
surface_type: str = "flat",
|
|
|
|
|
roc_mm: float = 0.0,
|
|
|
|
|
conic: float = 0.0,
|
2026-03-09 15:49:06 +00:00
|
|
|
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
|
|
|
"""
|
2026-03-09 15:56:23 +00:00
|
|
|
Generate a mirror mesh with realistic node distribution.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
diameter_mm: Mirror outer diameter in mm
|
|
|
|
|
inner_radius_mm: Inner radius (0 for full disk)
|
|
|
|
|
n_rings: Number of radial rings
|
|
|
|
|
surface_type: "flat", "sphere", "parabola"
|
|
|
|
|
roc_mm: Radius of curvature in mm (for sphere/parabola)
|
|
|
|
|
conic: Conic constant (-1 = parabola, 0 = sphere)
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
Returns:
|
2026-03-09 15:56:23 +00:00
|
|
|
x_m, y_m, z_m: Node positions in meters
|
2026-03-09 15:49:06 +00:00
|
|
|
"""
|
2026-03-09 15:56:23 +00:00
|
|
|
outer_r_mm = diameter_mm / 2.0
|
|
|
|
|
|
|
|
|
|
# Generate rings with increasing node count
|
|
|
|
|
all_x = []
|
|
|
|
|
all_y = []
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
if inner_radius_mm > 0:
|
2026-03-09 15:56:23 +00:00
|
|
|
r_values = np.linspace(inner_radius_mm, outer_r_mm, n_rings)
|
2026-03-09 15:49:06 +00:00
|
|
|
else:
|
2026-03-09 15:56:23 +00:00
|
|
|
# Include center point
|
|
|
|
|
r_values = np.linspace(0, outer_r_mm, n_rings + 1)
|
|
|
|
|
|
|
|
|
|
for i, r in enumerate(r_values):
|
|
|
|
|
if r < 1e-6:
|
|
|
|
|
all_x.append(0.0)
|
|
|
|
|
all_y.append(0.0)
|
|
|
|
|
else:
|
|
|
|
|
# More nodes at larger radii (like real FEA mesh)
|
|
|
|
|
n_pts = max(6, int(6 + i * 3))
|
|
|
|
|
angles = np.linspace(0, 2 * np.pi, n_pts, endpoint=False)
|
|
|
|
|
for a in angles:
|
|
|
|
|
all_x.append(r * np.cos(a))
|
|
|
|
|
all_y.append(r * np.sin(a))
|
|
|
|
|
|
|
|
|
|
x_mm = np.array(all_x)
|
|
|
|
|
y_mm = np.array(all_y)
|
|
|
|
|
|
|
|
|
|
# Compute Z (surface sag)
|
|
|
|
|
if surface_type == "flat":
|
|
|
|
|
z_mm = np.zeros_like(x_mm)
|
|
|
|
|
elif surface_type in ("sphere", "parabola"):
|
|
|
|
|
if roc_mm <= 0:
|
|
|
|
|
raise ValueError("roc_mm must be > 0 for curved surfaces")
|
|
|
|
|
r2 = x_mm**2 + y_mm**2
|
|
|
|
|
if surface_type == "sphere":
|
|
|
|
|
# z = R - sqrt(R^2 - r^2)
|
|
|
|
|
z_mm = roc_mm - np.sqrt(roc_mm**2 - r2)
|
|
|
|
|
else:
|
|
|
|
|
# Parabola: z = r^2 / (2R)
|
|
|
|
|
z_mm = r2 / (2 * roc_mm)
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(f"Unknown surface_type: {surface_type}")
|
|
|
|
|
|
|
|
|
|
# Convert to meters
|
|
|
|
|
return x_mm / 1000.0, y_mm / 1000.0, z_mm / 1000.0
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
def load_real_mesh(csv_path: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
|
|
|
"""
|
|
|
|
|
Load node positions from a real FEA CSV file.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
x_m, y_m, z_m: Node positions in meters
|
|
|
|
|
"""
|
|
|
|
|
x_list, y_list, z_list = [], [], []
|
|
|
|
|
with open(csv_path) as f:
|
|
|
|
|
reader = csv.DictReader(f)
|
|
|
|
|
for row in reader:
|
|
|
|
|
x_list.append(float(row['X']))
|
|
|
|
|
y_list.append(float(row['Y']))
|
|
|
|
|
z_list.append(float(row['Z']))
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
return np.array(x_list), np.array(y_list), np.array(z_list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Surface Synthesis
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def synthesize_displacements(
|
|
|
|
|
x_m: np.ndarray,
|
|
|
|
|
y_m: np.ndarray,
|
2026-03-09 15:49:06 +00:00
|
|
|
coefficients: Dict[int, float],
|
2026-03-09 15:56:23 +00:00
|
|
|
diameter_mm: float = None,
|
2026-03-09 15:49:06 +00:00
|
|
|
noise_rms_nm: float = 0.0,
|
2026-03-09 15:56:23 +00:00
|
|
|
include_lateral: bool = False,
|
2026-03-09 15:49:06 +00:00
|
|
|
seed: int = 42,
|
2026-03-09 15:56:23 +00:00
|
|
|
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Dict]:
|
2026-03-09 15:49:06 +00:00
|
|
|
"""
|
2026-03-09 15:56:23 +00:00
|
|
|
Generate synthetic displacement vectors from Zernike coefficients.
|
|
|
|
|
|
|
|
|
|
The Zernike content is injected into DZ (surface-normal displacement).
|
|
|
|
|
DX and DY are set to zero unless include_lateral is True (adds small
|
|
|
|
|
realistic lateral displacements).
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
Args:
|
2026-03-09 15:56:23 +00:00
|
|
|
x_m, y_m: Node positions in meters
|
|
|
|
|
coefficients: {Noll_index: amplitude_nm}
|
|
|
|
|
diameter_mm: Mirror diameter (auto-detected if None)
|
|
|
|
|
noise_rms_nm: Gaussian noise RMS in nm
|
|
|
|
|
include_lateral: Add small realistic DX/DY
|
|
|
|
|
seed: Random seed
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
Returns:
|
2026-03-09 15:56:23 +00:00
|
|
|
dx_m, dy_m, dz_m: Displacement vectors in meters
|
|
|
|
|
metadata: Ground truth info
|
2026-03-09 15:49:06 +00:00
|
|
|
"""
|
2026-03-09 15:56:23 +00:00
|
|
|
# Auto-detect diameter from mesh
|
|
|
|
|
if diameter_mm is None:
|
|
|
|
|
r_mm = np.sqrt(x_m**2 + y_m**2) * 1000.0
|
|
|
|
|
diameter_mm = 2.0 * np.max(r_mm)
|
|
|
|
|
|
|
|
|
|
outer_r_m = diameter_mm / 2000.0 # meters
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
# Normalize to unit disk
|
2026-03-09 15:56:23 +00:00
|
|
|
r_m = np.sqrt(x_m**2 + y_m**2)
|
|
|
|
|
r_norm = r_m / outer_r_m
|
|
|
|
|
theta = np.arctan2(y_m, x_m)
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Build DZ from Zernike modes (in nm, then convert to meters)
|
|
|
|
|
dz_nm = np.zeros_like(x_m)
|
2026-03-09 15:49:06 +00:00
|
|
|
for j, amp_nm in coefficients.items():
|
|
|
|
|
Z_j = zernike_noll(j, r_norm, theta)
|
2026-03-09 15:56:23 +00:00
|
|
|
dz_nm += amp_nm * Z_j
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
rms_clean_nm = np.sqrt(np.mean(dz_nm**2))
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Add noise
|
|
|
|
|
rng = np.random.default_rng(seed)
|
2026-03-09 15:49:06 +00:00
|
|
|
if noise_rms_nm > 0:
|
2026-03-09 15:56:23 +00:00
|
|
|
noise = rng.normal(0, noise_rms_nm, size=dz_nm.shape)
|
|
|
|
|
dz_nm += noise
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
rms_noisy_nm = np.sqrt(np.mean(dz_nm**2))
|
|
|
|
|
|
|
|
|
|
# Convert to meters
|
|
|
|
|
dz_m = dz_nm * 1e-9
|
|
|
|
|
|
|
|
|
|
# DX, DY
|
|
|
|
|
if include_lateral:
|
|
|
|
|
# Small lateral displacements (~1/10 of DZ magnitude)
|
|
|
|
|
scale = np.max(np.abs(dz_m)) * 0.1
|
|
|
|
|
dx_m = rng.normal(0, scale, size=x_m.shape)
|
|
|
|
|
dy_m = rng.normal(0, scale, size=y_m.shape)
|
|
|
|
|
else:
|
|
|
|
|
dx_m = np.zeros_like(x_m)
|
|
|
|
|
dy_m = np.zeros_like(y_m)
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
metadata = {
|
|
|
|
|
"input_coefficients": {str(j): amp for j, amp in coefficients.items()},
|
|
|
|
|
"coefficient_names": {str(j): zernike_name(j) for j in coefficients},
|
2026-03-09 15:56:23 +00:00
|
|
|
"n_points": len(x_m),
|
|
|
|
|
"diameter_mm": float(diameter_mm),
|
|
|
|
|
"rms_nm_clean": float(rms_clean_nm),
|
|
|
|
|
"rms_nm_with_noise": float(rms_noisy_nm),
|
2026-03-09 15:49:06 +00:00
|
|
|
"noise_rms_nm": noise_rms_nm,
|
2026-03-09 15:56:23 +00:00
|
|
|
"include_lateral": include_lateral,
|
2026-03-09 15:49:06 +00:00
|
|
|
"seed": seed,
|
2026-03-09 15:56:23 +00:00
|
|
|
"units": {
|
|
|
|
|
"positions": "meters",
|
|
|
|
|
"displacements": "meters",
|
|
|
|
|
"coefficients": "nanometers",
|
|
|
|
|
}
|
2026-03-09 15:49:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
return dx_m, dy_m, dz_m, metadata
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
2026-03-09 15:56:23 +00:00
|
|
|
# Output (matching WFE_from_CSV_OPD format exactly)
|
2026-03-09 15:49:06 +00:00
|
|
|
# ============================================================================
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
def write_csv_fea(
|
2026-03-09 15:49:06 +00:00
|
|
|
filepath: str,
|
2026-03-09 15:56:23 +00:00
|
|
|
x_m: np.ndarray,
|
|
|
|
|
y_m: np.ndarray,
|
|
|
|
|
z_m: np.ndarray,
|
|
|
|
|
dx_m: np.ndarray,
|
|
|
|
|
dy_m: np.ndarray,
|
|
|
|
|
dz_m: np.ndarray,
|
2026-03-09 15:49:06 +00:00
|
|
|
):
|
|
|
|
|
"""
|
2026-03-09 15:56:23 +00:00
|
|
|
Write FEA-style CSV matching the WFE_from_CSV_OPD input format.
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
Format:
|
|
|
|
|
,X,Y,Z,DX,DY,DZ
|
|
|
|
|
0,x,y,z,dx,dy,dz
|
|
|
|
|
1,...
|
2026-03-09 15:49:06 +00:00
|
|
|
"""
|
|
|
|
|
filepath = Path(filepath)
|
|
|
|
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
with open(filepath, 'w', newline='') as f:
|
|
|
|
|
writer = csv.writer(f)
|
|
|
|
|
writer.writerow(['', 'X', 'Y', 'Z', 'DX', 'DY', 'DZ'])
|
|
|
|
|
for i in range(len(x_m)):
|
|
|
|
|
writer.writerow([
|
|
|
|
|
i,
|
|
|
|
|
f"{x_m[i]:.16e}",
|
|
|
|
|
f"{y_m[i]:.16e}",
|
|
|
|
|
f"{z_m[i]:.16e}",
|
|
|
|
|
f"{dx_m[i]:.5e}",
|
|
|
|
|
f"{dy_m[i]:.16e}",
|
|
|
|
|
f"{dz_m[i]:.16e}",
|
|
|
|
|
])
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
print(f" Written: {filepath} ({len(x_m)} nodes)")
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_metadata(filepath: str, metadata: Dict):
|
|
|
|
|
"""Write ground truth metadata as JSON."""
|
|
|
|
|
filepath = Path(filepath)
|
|
|
|
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(filepath, 'w') as f:
|
|
|
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
|
print(f" Metadata: {filepath}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Preset Test Cases
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
# Realistic M1 mirror: typical gravity-induced deformation pattern
|
|
|
|
|
PRESET_REALISTIC = {
|
|
|
|
|
5: 80.0, # Astigmatism 0° — dominant gravity mode
|
|
|
|
|
6: 45.0, # Astigmatism 45°
|
|
|
|
|
7: 30.0, # Coma X
|
|
|
|
|
8: 20.0, # Coma Y
|
|
|
|
|
9: 15.0, # Trefoil X
|
|
|
|
|
11: 10.0, # Primary Spherical
|
|
|
|
|
13: 5.0, # Secondary Astigmatism
|
|
|
|
|
16: 3.0, # Secondary Coma
|
|
|
|
|
22: 2.0, # Secondary Spherical
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Single-mode test cases
|
|
|
|
|
SINGLE_MODE_TESTS = {
|
|
|
|
|
"Z05_astig_0deg": {5: 100.0},
|
|
|
|
|
"Z06_astig_45deg": {6: 100.0},
|
|
|
|
|
"Z07_coma_x": {7: 100.0},
|
|
|
|
|
"Z08_coma_y": {8: 100.0},
|
|
|
|
|
"Z09_trefoil_x": {9: 100.0},
|
|
|
|
|
"Z10_trefoil_y": {10: 100.0},
|
|
|
|
|
"Z11_spherical": {11: 100.0},
|
|
|
|
|
"Z22_2nd_spherical": {22: 50.0},
|
|
|
|
|
"Z37_high_order": {37: 30.0},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Edge case tests
|
|
|
|
|
EDGE_CASE_TESTS = {
|
2026-03-09 15:56:23 +00:00
|
|
|
"near_zero": {5: 0.1, 7: 0.05, 11: 0.01},
|
|
|
|
|
"large_amplitude": {5: 500.0, 7: 300.0},
|
|
|
|
|
"many_modes": {j: 100.0 / j for j in range(5, 51)},
|
2026-03-09 15:49:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
def generate_single_case(
|
|
|
|
|
name: str,
|
|
|
|
|
coeffs: Dict[int, float],
|
|
|
|
|
output_dir: Path,
|
|
|
|
|
x_m: np.ndarray,
|
|
|
|
|
y_m: np.ndarray,
|
|
|
|
|
z_m: np.ndarray,
|
|
|
|
|
diameter_mm: float,
|
|
|
|
|
noise_rms_nm: float = 0.0,
|
|
|
|
|
include_lateral: bool = False,
|
|
|
|
|
):
|
|
|
|
|
"""Generate a single test case."""
|
|
|
|
|
print(f"\nGenerating: {name}")
|
|
|
|
|
dx, dy, dz, meta = synthesize_displacements(
|
|
|
|
|
x_m, y_m, coeffs, diameter_mm,
|
|
|
|
|
noise_rms_nm=noise_rms_nm,
|
|
|
|
|
include_lateral=include_lateral,
|
|
|
|
|
)
|
|
|
|
|
write_csv_fea(output_dir / f"{name}.csv", x_m, y_m, z_m, dx, dy, dz)
|
|
|
|
|
write_metadata(output_dir / f"{name}_truth.json", meta)
|
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 15:49:06 +00:00
|
|
|
def generate_test_suite(
|
|
|
|
|
output_dir: str,
|
2026-03-09 15:56:23 +00:00
|
|
|
inject_csv: str = None,
|
|
|
|
|
diameter_mm: float = None,
|
|
|
|
|
inner_radius_mm: float = 0.0,
|
|
|
|
|
surface_type: str = "flat",
|
|
|
|
|
roc_mm: float = 0.0,
|
2026-03-09 15:49:06 +00:00
|
|
|
):
|
|
|
|
|
"""Generate the full validation test suite."""
|
|
|
|
|
output_dir = Path(output_dir)
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Get mesh
|
|
|
|
|
if inject_csv:
|
|
|
|
|
print(f"Loading real mesh from: {inject_csv}")
|
|
|
|
|
x_m, y_m, z_m = load_real_mesh(inject_csv)
|
|
|
|
|
if diameter_mm is None:
|
|
|
|
|
r_mm = np.sqrt(x_m**2 + y_m**2) * 1000.0
|
|
|
|
|
diameter_mm = 2.0 * np.max(r_mm)
|
|
|
|
|
mesh_source = f"real FEA mesh ({inject_csv})"
|
|
|
|
|
else:
|
|
|
|
|
if diameter_mm is None:
|
|
|
|
|
diameter_mm = 1200.0
|
|
|
|
|
x_m, y_m, z_m = generate_mesh(
|
|
|
|
|
diameter_mm, inner_radius_mm,
|
|
|
|
|
n_rings=25, surface_type=surface_type, roc_mm=roc_mm
|
|
|
|
|
)
|
|
|
|
|
mesh_source = f"synthetic {surface_type} (D={diameter_mm}mm)"
|
|
|
|
|
|
|
|
|
|
print(f"\n Mesh: {mesh_source}")
|
|
|
|
|
print(f" Nodes: {len(x_m)}")
|
|
|
|
|
print(f" Diameter: {diameter_mm:.1f} mm")
|
|
|
|
|
|
2026-03-09 15:49:06 +00:00
|
|
|
all_cases = {}
|
|
|
|
|
|
|
|
|
|
# 1. Single-mode tests
|
|
|
|
|
print("\n=== Single-Mode Tests ===")
|
|
|
|
|
for name, coeffs in SINGLE_MODE_TESTS.items():
|
2026-03-09 15:56:23 +00:00
|
|
|
meta = generate_single_case(name, coeffs, output_dir, x_m, y_m, z_m, diameter_mm)
|
2026-03-09 15:49:06 +00:00
|
|
|
all_cases[name] = meta
|
|
|
|
|
|
|
|
|
|
# 2. Realistic multi-mode
|
|
|
|
|
print("\n=== Realistic Multi-Mode ===")
|
2026-03-09 15:56:23 +00:00
|
|
|
meta = generate_single_case("realistic_gravity", PRESET_REALISTIC, output_dir,
|
|
|
|
|
x_m, y_m, z_m, diameter_mm)
|
|
|
|
|
all_cases["realistic_gravity"] = meta
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
# 3. Noisy versions
|
|
|
|
|
print("\n=== Noisy Tests ===")
|
|
|
|
|
for noise_level in [1.0, 5.0, 10.0]:
|
|
|
|
|
name = f"realistic_noise_{noise_level:.0f}nm"
|
2026-03-09 15:56:23 +00:00
|
|
|
meta = generate_single_case(name, PRESET_REALISTIC, output_dir,
|
|
|
|
|
x_m, y_m, z_m, diameter_mm,
|
|
|
|
|
noise_rms_nm=noise_level)
|
2026-03-09 15:49:06 +00:00
|
|
|
all_cases[name] = meta
|
|
|
|
|
|
|
|
|
|
# 4. Edge cases
|
|
|
|
|
print("\n=== Edge Case Tests ===")
|
|
|
|
|
for name, coeffs in EDGE_CASE_TESTS.items():
|
2026-03-09 15:56:23 +00:00
|
|
|
meta = generate_single_case(name, coeffs, output_dir, x_m, y_m, z_m, diameter_mm)
|
2026-03-09 15:49:06 +00:00
|
|
|
all_cases[name] = meta
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# 5. With lateral displacement (tests rigorous OPD method)
|
|
|
|
|
print("\n=== Lateral Displacement Test ===")
|
|
|
|
|
meta = generate_single_case("realistic_with_lateral", PRESET_REALISTIC, output_dir,
|
|
|
|
|
x_m, y_m, z_m, diameter_mm, include_lateral=True)
|
|
|
|
|
all_cases["realistic_with_lateral"] = meta
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
# Summary
|
|
|
|
|
print(f"\n{'='*60}")
|
|
|
|
|
print(f"Generated {len(all_cases)} test cases in: {output_dir}")
|
2026-03-09 15:56:23 +00:00
|
|
|
print(f"Mesh source: {mesh_source}")
|
2026-03-09 15:49:06 +00:00
|
|
|
print(f"{'='*60}")
|
|
|
|
|
|
|
|
|
|
# Write suite manifest
|
|
|
|
|
manifest = {
|
|
|
|
|
"suite": "Zernike Pipeline Validation",
|
|
|
|
|
"generated": "2026-03-09",
|
2026-03-09 15:56:23 +00:00
|
|
|
"mesh_source": mesh_source,
|
|
|
|
|
"diameter_mm": diameter_mm,
|
|
|
|
|
"n_modes": 50,
|
2026-03-09 15:49:06 +00:00
|
|
|
"cases": all_cases,
|
|
|
|
|
}
|
|
|
|
|
manifest_path = output_dir / "suite_manifest.json"
|
|
|
|
|
with open(manifest_path, 'w') as f:
|
|
|
|
|
json.dump(manifest, f, indent=2)
|
|
|
|
|
print(f"Manifest: {manifest_path}")
|
|
|
|
|
|
|
|
|
|
return all_cases
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# CLI
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def parse_zernike_string(s: str) -> Dict[int, float]:
|
|
|
|
|
"""Parse 'j1:amp1,j2:amp2,...' format."""
|
|
|
|
|
coeffs = {}
|
|
|
|
|
for pair in s.split(","):
|
|
|
|
|
parts = pair.strip().split(":")
|
|
|
|
|
if len(parts) != 2:
|
|
|
|
|
raise ValueError(f"Invalid format: '{pair}'. Use 'j:amplitude'")
|
|
|
|
|
j = int(parts[0])
|
|
|
|
|
amp = float(parts[1])
|
|
|
|
|
coeffs[j] = amp
|
|
|
|
|
return coeffs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="Generate synthetic WFE surfaces for Zernike validation",
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
epilog="""
|
|
|
|
|
Examples:
|
2026-03-09 15:56:23 +00:00
|
|
|
# Pure astigmatism (100nm) on flat synthetic mesh
|
2026-03-09 15:49:06 +00:00
|
|
|
python generate_synthetic_wfe.py --zernike "5:100" -o test_astig.csv
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Realistic multi-mode
|
2026-03-09 15:49:06 +00:00
|
|
|
python generate_synthetic_wfe.py --preset realistic -o test_realistic.csv
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Inject into real M2 mesh
|
|
|
|
|
python generate_synthetic_wfe.py --inject m2_real.csv --zernike "5:100,7:50" -o test.csv
|
|
|
|
|
|
|
|
|
|
# Full suite using real mesh geometry
|
|
|
|
|
python generate_synthetic_wfe.py --suite --inject m2_real.csv -d validation_suite/
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Full suite with synthetic spherical mirror
|
|
|
|
|
python generate_synthetic_wfe.py --suite --surface sphere --roc 5000 -d validation_suite/
|
2026-03-09 15:49:06 +00:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
parser.add_argument("--zernike", "-z", type=str,
|
2026-03-09 15:56:23 +00:00
|
|
|
help="Zernike coefficients as 'j:amp_nm,...'")
|
2026-03-09 15:49:06 +00:00
|
|
|
parser.add_argument("--preset", choices=["realistic"],
|
|
|
|
|
help="Use preset coefficient set")
|
|
|
|
|
parser.add_argument("--suite", action="store_true",
|
|
|
|
|
help="Generate full validation test suite")
|
2026-03-09 15:56:23 +00:00
|
|
|
parser.add_argument("--inject", type=str,
|
|
|
|
|
help="Real FEA CSV to use as mesh source (keeps geometry, replaces displacements)")
|
2026-03-09 15:49:06 +00:00
|
|
|
parser.add_argument("--output", "-o", type=str, default="synthetic_wfe.csv",
|
|
|
|
|
help="Output CSV file path")
|
|
|
|
|
parser.add_argument("--output-dir", "-d", type=str, default="validation_suite",
|
|
|
|
|
help="Output directory for --suite mode")
|
2026-03-09 15:56:23 +00:00
|
|
|
parser.add_argument("--diameter", type=float, default=None,
|
|
|
|
|
help="Mirror diameter in mm (auto-detected from mesh if not set)")
|
|
|
|
|
parser.add_argument("--inner-radius", type=float, default=0.0,
|
|
|
|
|
help="Inner radius in mm for annular aperture")
|
|
|
|
|
parser.add_argument("--surface", choices=["flat", "sphere", "parabola"],
|
|
|
|
|
default="flat", help="Surface type for synthetic mesh")
|
|
|
|
|
parser.add_argument("--roc", type=float, default=5000.0,
|
|
|
|
|
help="Radius of curvature in mm (for sphere/parabola)")
|
2026-03-09 15:49:06 +00:00
|
|
|
parser.add_argument("--noise", type=float, default=0.0,
|
2026-03-09 15:56:23 +00:00
|
|
|
help="Gaussian noise RMS in nm")
|
|
|
|
|
parser.add_argument("--lateral", action="store_true",
|
|
|
|
|
help="Include small lateral DX/DY displacements")
|
2026-03-09 15:49:06 +00:00
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
print("=" * 60)
|
|
|
|
|
print("Synthetic WFE Surface Generator")
|
2026-03-09 15:56:23 +00:00
|
|
|
print("Zernike Pipeline Validation — GigaBIT")
|
2026-03-09 15:49:06 +00:00
|
|
|
print("=" * 60)
|
|
|
|
|
|
|
|
|
|
if args.suite:
|
|
|
|
|
generate_test_suite(
|
|
|
|
|
args.output_dir,
|
2026-03-09 15:56:23 +00:00
|
|
|
inject_csv=args.inject,
|
|
|
|
|
diameter_mm=args.diameter,
|
|
|
|
|
inner_radius_mm=args.inner_radius,
|
|
|
|
|
surface_type=args.surface,
|
|
|
|
|
roc_mm=args.roc,
|
2026-03-09 15:49:06 +00:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Determine coefficients
|
|
|
|
|
if args.preset == "realistic":
|
|
|
|
|
coeffs = PRESET_REALISTIC
|
|
|
|
|
elif args.zernike:
|
|
|
|
|
coeffs = parse_zernike_string(args.zernike)
|
|
|
|
|
else:
|
|
|
|
|
print("\nERROR: Specify --zernike, --preset, or --suite")
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
# Get mesh
|
|
|
|
|
if args.inject:
|
|
|
|
|
x_m, y_m, z_m = load_real_mesh(args.inject)
|
|
|
|
|
diameter_mm = args.diameter
|
|
|
|
|
else:
|
|
|
|
|
diameter_mm = args.diameter or 1200.0
|
|
|
|
|
x_m, y_m, z_m = generate_mesh(
|
|
|
|
|
diameter_mm, args.inner_radius,
|
|
|
|
|
n_rings=25, surface_type=args.surface, roc_mm=args.roc
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
print(f" Nodes: {len(x_m)}")
|
|
|
|
|
if diameter_mm:
|
|
|
|
|
print(f" Diameter: {diameter_mm:.1f} mm")
|
|
|
|
|
|
2026-03-09 15:49:06 +00:00
|
|
|
print(f"\n Coefficients:")
|
|
|
|
|
for j, amp in sorted(coeffs.items()):
|
|
|
|
|
print(f" Z{j:2d} ({zernike_name(j):20s}): {amp:8.2f} nm")
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
dx, dy, dz, meta = synthesize_displacements(
|
|
|
|
|
x_m, y_m, coeffs, diameter_mm,
|
|
|
|
|
noise_rms_nm=args.noise,
|
|
|
|
|
include_lateral=args.lateral,
|
|
|
|
|
)
|
2026-03-09 15:49:06 +00:00
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
print(f"\n RMS (clean): {meta['rms_nm_clean']:.3f} nm")
|
2026-03-09 15:49:06 +00:00
|
|
|
if args.noise > 0:
|
|
|
|
|
print(f" RMS (noisy): {meta['rms_nm_with_noise']:.3f} nm")
|
|
|
|
|
|
2026-03-09 15:56:23 +00:00
|
|
|
write_csv_fea(args.output, x_m, y_m, z_m, dx, dy, dz)
|
2026-03-09 15:49:06 +00:00
|
|
|
meta_path = Path(args.output).with_suffix('.json')
|
|
|
|
|
write_metadata(str(meta_path), meta)
|
|
|
|
|
|
|
|
|
|
print("\nDone!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|