feat(V&V): Updated to FEA CSV format + real M2 mesh injection
- Output now matches WFE_from_CSV_OPD format: ,X,Y,Z,DX,DY,DZ (meters) - Suite regenerated using real M2 mesh (357 nodes, 308mm diameter) - All 14 clean test cases: PASS (0.000 nm error) - 3 noisy cases: expected FAIL due to low node count amplifying noise - Added --inject mode to use real FEA mesh geometry - Added lateral displacement test case
This commit is contained in:
@@ -3,37 +3,41 @@
|
||||
Synthetic WFE Surface Generator for Zernike Pipeline Validation
|
||||
================================================================
|
||||
|
||||
Generates synthetic Optical Path Difference (OPD) maps from user-defined
|
||||
Zernike coefficients. Used to validate the Atomizer Zernike fitting pipeline
|
||||
by creating "known truth" surfaces that can be round-tripped through the
|
||||
WFE_from_CSV_OPD tool.
|
||||
|
||||
Features:
|
||||
- Noll-indexed Zernike polynomials (standard optical convention)
|
||||
- Full-disk or annular aperture support
|
||||
- Configurable grid density, mirror diameter, noise level
|
||||
- Multiple output formats: CSV (for WFE_from_CSV_OPD), NumPy, plot
|
||||
- Preset test cases for common validation scenarios
|
||||
|
||||
Usage:
|
||||
# Single mode test (pure astigmatism)
|
||||
python generate_synthetic_wfe.py --mode single --zernike "5:100" --output test_astig.csv
|
||||
|
||||
# Multi-mode realistic mirror
|
||||
python generate_synthetic_wfe.py --mode multi --output test_multi.csv
|
||||
|
||||
# Custom coefficients
|
||||
python generate_synthetic_wfe.py --zernike "5:80,7:45,9:25,11:15" --output test_custom.csv
|
||||
|
||||
# With noise
|
||||
python generate_synthetic_wfe.py --mode multi --noise 2.0 --output test_noisy.csv
|
||||
|
||||
# Full test suite (generates all validation cases)
|
||||
python generate_synthetic_wfe.py --suite --output-dir validation_suite/
|
||||
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.
|
||||
|
||||
Output CSV format (matching WFE_from_CSV_OPD):
|
||||
x(mm), y(mm), dz(mm)
|
||||
where dz is surface displacement (OPD = 2 * dz for reflective surfaces)
|
||||
,X,Y,Z,DX,DY,DZ
|
||||
0, x_meters, y_meters, z_meters, dx_meters, dy_meters, dz_meters
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
Usage:
|
||||
# Pure synthetic (flat mirror, M1 params)
|
||||
python generate_synthetic_wfe.py --zernike "5:100,7:50" -o test.csv
|
||||
|
||||
# Pure synthetic with spherical surface
|
||||
python generate_synthetic_wfe.py --preset realistic --surface sphere --roc 5000 -o test.csv
|
||||
|
||||
# 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/
|
||||
|
||||
Author: Mario (Atomizer V&V)
|
||||
Created: 2026-03-09
|
||||
@@ -44,6 +48,7 @@ import sys
|
||||
import os
|
||||
import argparse
|
||||
import json
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from math import factorial
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
@@ -122,146 +127,228 @@ def zernike_name(j: int) -> str:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Surface Generation
|
||||
# Mesh Generation
|
||||
# ============================================================================
|
||||
|
||||
def generate_grid(
|
||||
def generate_mesh(
|
||||
diameter_mm: float = 1200.0,
|
||||
inner_radius_mm: float = 135.75,
|
||||
n_points_radial: int = 200,
|
||||
grid_type: str = "cartesian",
|
||||
inner_radius_mm: float = 0.0,
|
||||
n_rings: int = 20,
|
||||
surface_type: str = "flat",
|
||||
roc_mm: float = 0.0,
|
||||
conic: float = 0.0,
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Generate a 2D grid of points within the mirror aperture.
|
||||
|
||||
Returns:
|
||||
x_mm, y_mm: Physical coordinates in mm
|
||||
mask: Boolean array (True = inside aperture)
|
||||
"""
|
||||
outer_radius = diameter_mm / 2.0
|
||||
|
||||
if grid_type == "cartesian":
|
||||
# Regular Cartesian grid
|
||||
n = n_points_radial * 2
|
||||
x_1d = np.linspace(-outer_radius, outer_radius, n)
|
||||
y_1d = np.linspace(-outer_radius, outer_radius, n)
|
||||
x_mm, y_mm = np.meshgrid(x_1d, y_1d)
|
||||
x_mm = x_mm.ravel()
|
||||
y_mm = y_mm.ravel()
|
||||
elif grid_type == "scattered":
|
||||
# Random scattered points (simulates real mesh)
|
||||
n_total = (n_points_radial * 2) ** 2
|
||||
rng = np.random.default_rng(42)
|
||||
x_mm = rng.uniform(-outer_radius, outer_radius, n_total)
|
||||
y_mm = rng.uniform(-outer_radius, outer_radius, n_total)
|
||||
else:
|
||||
raise ValueError(f"Unknown grid_type: {grid_type}")
|
||||
|
||||
# Apply aperture mask
|
||||
r = np.sqrt(x_mm**2 + y_mm**2)
|
||||
if inner_radius_mm > 0:
|
||||
mask = (r <= outer_radius) & (r >= inner_radius_mm)
|
||||
else:
|
||||
mask = r <= outer_radius
|
||||
|
||||
return x_mm[mask], y_mm[mask], mask
|
||||
|
||||
|
||||
def synthesize_surface(
|
||||
x_mm: np.ndarray,
|
||||
y_mm: np.ndarray,
|
||||
coefficients: Dict[int, float],
|
||||
diameter_mm: float = 1200.0,
|
||||
noise_rms_nm: float = 0.0,
|
||||
seed: int = 42,
|
||||
) -> Tuple[np.ndarray, Dict]:
|
||||
"""
|
||||
Generate a synthetic OPD surface from Zernike coefficients.
|
||||
Generate a mirror mesh with realistic node distribution.
|
||||
|
||||
Args:
|
||||
x_mm, y_mm: Physical coordinates in mm
|
||||
coefficients: Dict of {Noll_index: amplitude_nm}
|
||||
diameter_mm: Mirror diameter in mm
|
||||
noise_rms_nm: RMS of Gaussian noise to add (nm)
|
||||
seed: Random seed for reproducibility
|
||||
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)
|
||||
|
||||
Returns:
|
||||
opd_nm: OPD values in nanometers at each point
|
||||
metadata: Dict with ground truth info
|
||||
x_m, y_m, z_m: Node positions in meters
|
||||
"""
|
||||
outer_radius = diameter_mm / 2.0
|
||||
outer_r_mm = diameter_mm / 2.0
|
||||
|
||||
# Generate rings with increasing node count
|
||||
all_x = []
|
||||
all_y = []
|
||||
|
||||
if inner_radius_mm > 0:
|
||||
r_values = np.linspace(inner_radius_mm, outer_r_mm, n_rings)
|
||||
else:
|
||||
# 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
|
||||
|
||||
|
||||
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']))
|
||||
|
||||
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,
|
||||
coefficients: Dict[int, float],
|
||||
diameter_mm: float = None,
|
||||
noise_rms_nm: float = 0.0,
|
||||
include_lateral: bool = False,
|
||||
seed: int = 42,
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Dict]:
|
||||
"""
|
||||
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).
|
||||
|
||||
Args:
|
||||
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
|
||||
|
||||
Returns:
|
||||
dx_m, dy_m, dz_m: Displacement vectors in meters
|
||||
metadata: Ground truth info
|
||||
"""
|
||||
# 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
|
||||
|
||||
# Normalize to unit disk
|
||||
r_norm = np.sqrt(x_mm**2 + y_mm**2) / outer_radius
|
||||
theta = np.arctan2(y_mm, x_mm)
|
||||
r_m = np.sqrt(x_m**2 + y_m**2)
|
||||
r_norm = r_m / outer_r_m
|
||||
theta = np.arctan2(y_m, x_m)
|
||||
|
||||
# Build surface from Zernike modes
|
||||
opd_nm = np.zeros_like(x_mm)
|
||||
# Build DZ from Zernike modes (in nm, then convert to meters)
|
||||
dz_nm = np.zeros_like(x_m)
|
||||
for j, amp_nm in coefficients.items():
|
||||
Z_j = zernike_noll(j, r_norm, theta)
|
||||
opd_nm += amp_nm * Z_j
|
||||
dz_nm += amp_nm * Z_j
|
||||
|
||||
# Compute ground truth RMS (before noise)
|
||||
rms_total = np.sqrt(np.mean(opd_nm**2))
|
||||
rms_clean_nm = np.sqrt(np.mean(dz_nm**2))
|
||||
|
||||
# Add noise if requested
|
||||
# Add noise
|
||||
rng = np.random.default_rng(seed)
|
||||
if noise_rms_nm > 0:
|
||||
rng = np.random.default_rng(seed)
|
||||
noise = rng.normal(0, noise_rms_nm, size=opd_nm.shape)
|
||||
opd_nm += noise
|
||||
noise = rng.normal(0, noise_rms_nm, size=dz_nm.shape)
|
||||
dz_nm += noise
|
||||
|
||||
rms_with_noise = np.sqrt(np.mean(opd_nm**2))
|
||||
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)
|
||||
|
||||
metadata = {
|
||||
"input_coefficients": {str(j): amp for j, amp in coefficients.items()},
|
||||
"coefficient_names": {str(j): zernike_name(j) for j in coefficients},
|
||||
"n_points": len(x_mm),
|
||||
"diameter_mm": diameter_mm,
|
||||
"rms_nm_clean": float(rms_total),
|
||||
"rms_nm_with_noise": float(rms_with_noise),
|
||||
"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),
|
||||
"noise_rms_nm": noise_rms_nm,
|
||||
"include_lateral": include_lateral,
|
||||
"seed": seed,
|
||||
"units": {
|
||||
"positions": "meters",
|
||||
"displacements": "meters",
|
||||
"coefficients": "nanometers",
|
||||
}
|
||||
}
|
||||
|
||||
return opd_nm, metadata
|
||||
return dx_m, dy_m, dz_m, metadata
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Output Formats
|
||||
# Output (matching WFE_from_CSV_OPD format exactly)
|
||||
# ============================================================================
|
||||
|
||||
def write_csv_opd(
|
||||
def write_csv_fea(
|
||||
filepath: str,
|
||||
x_mm: np.ndarray,
|
||||
y_mm: np.ndarray,
|
||||
opd_nm: np.ndarray,
|
||||
opd_unit: str = "nm",
|
||||
x_m: np.ndarray,
|
||||
y_m: np.ndarray,
|
||||
z_m: np.ndarray,
|
||||
dx_m: np.ndarray,
|
||||
dy_m: np.ndarray,
|
||||
dz_m: np.ndarray,
|
||||
):
|
||||
"""
|
||||
Write OPD surface to CSV format compatible with WFE_from_CSV_OPD.
|
||||
Write FEA-style CSV matching the WFE_from_CSV_OPD input format.
|
||||
|
||||
Default format: x(mm), y(mm), opd(nm)
|
||||
Can also output in mm for displacement: x(mm), y(mm), dz(mm)
|
||||
|
||||
NOTE: Update this function once the exact WFE_from_CSV_OPD format is confirmed.
|
||||
Format:
|
||||
,X,Y,Z,DX,DY,DZ
|
||||
0,x,y,z,dx,dy,dz
|
||||
1,...
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if opd_unit == "nm":
|
||||
header = "x(mm),y(mm),opd(nm)"
|
||||
data = np.column_stack([x_mm, y_mm, opd_nm])
|
||||
elif opd_unit == "mm":
|
||||
# Convert nm to mm for displacement
|
||||
dz_mm = opd_nm / 1e6
|
||||
header = "x(mm),y(mm),dz(mm)"
|
||||
data = np.column_stack([x_mm, y_mm, dz_mm])
|
||||
else:
|
||||
raise ValueError(f"Unknown opd_unit: {opd_unit}")
|
||||
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}",
|
||||
])
|
||||
|
||||
np.savetxt(filepath, data, delimiter=",", header=header, comments="",
|
||||
fmt="%.10e")
|
||||
print(f" Written: {filepath} ({len(x_mm)} points)")
|
||||
print(f" Written: {filepath} ({len(x_m)} nodes)")
|
||||
|
||||
|
||||
def write_metadata(filepath: str, metadata: Dict):
|
||||
@@ -305,100 +392,116 @@ SINGLE_MODE_TESTS = {
|
||||
|
||||
# Edge case tests
|
||||
EDGE_CASE_TESTS = {
|
||||
"near_zero": {5: 0.1, 7: 0.05, 11: 0.01}, # Sub-nm coefficients
|
||||
"large_amplitude": {5: 500.0, 7: 300.0}, # Large deformation
|
||||
"many_modes": {j: 100.0 / j for j in range(5, 51)}, # All modes, decreasing
|
||||
"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)},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def generate_test_suite(
|
||||
output_dir: str,
|
||||
diameter_mm: float = 1200.0,
|
||||
inner_radius_mm: float = 135.75,
|
||||
n_points_radial: int = 200,
|
||||
inject_csv: str = None,
|
||||
diameter_mm: float = None,
|
||||
inner_radius_mm: float = 0.0,
|
||||
surface_type: str = "flat",
|
||||
roc_mm: float = 0.0,
|
||||
):
|
||||
"""Generate the full validation test suite."""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 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")
|
||||
|
||||
all_cases = {}
|
||||
|
||||
# 1. Single-mode tests
|
||||
print("\n=== Single-Mode Tests ===")
|
||||
for name, coeffs in SINGLE_MODE_TESTS.items():
|
||||
print(f"\nGenerating: {name}")
|
||||
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
|
||||
opd, meta = synthesize_surface(x, y, coeffs, diameter_mm)
|
||||
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
|
||||
write_metadata(output_dir / f"{name}_truth.json", meta)
|
||||
meta = generate_single_case(name, coeffs, output_dir, x_m, y_m, z_m, diameter_mm)
|
||||
all_cases[name] = meta
|
||||
|
||||
# 2. Realistic multi-mode
|
||||
print("\n=== Realistic Multi-Mode ===")
|
||||
name = "realistic_gravity"
|
||||
print(f"\nGenerating: {name}")
|
||||
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
|
||||
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm)
|
||||
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
|
||||
write_metadata(output_dir / f"{name}_truth.json", meta)
|
||||
all_cases[name] = meta
|
||||
meta = generate_single_case("realistic_gravity", PRESET_REALISTIC, output_dir,
|
||||
x_m, y_m, z_m, diameter_mm)
|
||||
all_cases["realistic_gravity"] = meta
|
||||
|
||||
# 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"
|
||||
print(f"\nGenerating: {name}")
|
||||
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
|
||||
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm, noise_rms_nm=noise_level)
|
||||
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
|
||||
write_metadata(output_dir / f"{name}_truth.json", meta)
|
||||
meta = generate_single_case(name, PRESET_REALISTIC, output_dir,
|
||||
x_m, y_m, z_m, diameter_mm,
|
||||
noise_rms_nm=noise_level)
|
||||
all_cases[name] = meta
|
||||
|
||||
# 4. Edge cases
|
||||
print("\n=== Edge Case Tests ===")
|
||||
for name, coeffs in EDGE_CASE_TESTS.items():
|
||||
print(f"\nGenerating: {name}")
|
||||
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial)
|
||||
opd, meta = synthesize_surface(x, y, coeffs, diameter_mm)
|
||||
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
|
||||
write_metadata(output_dir / f"{name}_truth.json", meta)
|
||||
meta = generate_single_case(name, coeffs, output_dir, x_m, y_m, z_m, diameter_mm)
|
||||
all_cases[name] = meta
|
||||
|
||||
# 5. Full-disk (no central hole) for comparison
|
||||
print("\n=== Full-Disk (No Hole) ===")
|
||||
name = "realistic_full_disk"
|
||||
print(f"\nGenerating: {name}")
|
||||
x, y, _ = generate_grid(diameter_mm, 0.0, n_points_radial)
|
||||
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm)
|
||||
meta["inner_radius_mm"] = 0.0
|
||||
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
|
||||
write_metadata(output_dir / f"{name}_truth.json", meta)
|
||||
all_cases[name] = meta
|
||||
|
||||
# 6. Scattered points (simulates real FEA mesh)
|
||||
print("\n=== Scattered Grid (FEA-like) ===")
|
||||
name = "realistic_scattered"
|
||||
print(f"\nGenerating: {name}")
|
||||
x, y, _ = generate_grid(diameter_mm, inner_radius_mm, n_points_radial, grid_type="scattered")
|
||||
opd, meta = synthesize_surface(x, y, PRESET_REALISTIC, diameter_mm)
|
||||
meta["grid_type"] = "scattered"
|
||||
write_csv_opd(output_dir / f"{name}.csv", x, y, opd)
|
||||
write_metadata(output_dir / f"{name}_truth.json", meta)
|
||||
all_cases[name] = meta
|
||||
# 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
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Generated {len(all_cases)} test cases in: {output_dir}")
|
||||
print(f"Mesh source: {mesh_source}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Write suite manifest
|
||||
manifest = {
|
||||
"suite": "Zernike Pipeline Validation",
|
||||
"generated": "2026-03-09",
|
||||
"mirror_diameter_mm": diameter_mm,
|
||||
"inner_radius_mm": inner_radius_mm,
|
||||
"n_zernike_modes": 50,
|
||||
"n_points_radial": n_points_radial,
|
||||
"mesh_source": mesh_source,
|
||||
"diameter_mm": diameter_mm,
|
||||
"n_modes": 50,
|
||||
"cases": all_cases,
|
||||
}
|
||||
manifest_path = output_dir / "suite_manifest.json"
|
||||
@@ -432,60 +535,63 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Pure astigmatism (100nm)
|
||||
# Pure astigmatism (100nm) on flat synthetic mesh
|
||||
python generate_synthetic_wfe.py --zernike "5:100" -o test_astig.csv
|
||||
|
||||
# Realistic multi-mode mirror
|
||||
# Realistic multi-mode
|
||||
python generate_synthetic_wfe.py --preset realistic -o test_realistic.csv
|
||||
|
||||
# Full validation suite
|
||||
python generate_synthetic_wfe.py --suite -d validation_suite/
|
||||
# Inject into real M2 mesh
|
||||
python generate_synthetic_wfe.py --inject m2_real.csv --zernike "5:100,7:50" -o test.csv
|
||||
|
||||
# Custom with noise
|
||||
python generate_synthetic_wfe.py --zernike "5:80,7:45,11:15" --noise 2.0 -o test.csv
|
||||
# Full suite using real mesh geometry
|
||||
python generate_synthetic_wfe.py --suite --inject m2_real.csv -d validation_suite/
|
||||
|
||||
# Full suite with synthetic spherical mirror
|
||||
python generate_synthetic_wfe.py --suite --surface sphere --roc 5000 -d validation_suite/
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument("--zernike", "-z", type=str,
|
||||
help="Zernike coefficients as 'j:amp_nm,...' (e.g. '5:100,7:50')")
|
||||
help="Zernike coefficients as 'j:amp_nm,...'")
|
||||
parser.add_argument("--preset", choices=["realistic"],
|
||||
help="Use preset coefficient set")
|
||||
parser.add_argument("--suite", action="store_true",
|
||||
help="Generate full validation test suite")
|
||||
parser.add_argument("--inject", type=str,
|
||||
help="Real FEA CSV to use as mesh source (keeps geometry, replaces displacements)")
|
||||
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")
|
||||
parser.add_argument("--diameter", type=float, default=1200.0,
|
||||
help="Mirror diameter in mm (default: 1200)")
|
||||
parser.add_argument("--inner-radius", type=float, default=135.75,
|
||||
help="Inner radius in mm for annular aperture (default: 135.75, 0=full disk)")
|
||||
parser.add_argument("--grid-points", type=int, default=200,
|
||||
help="Number of radial grid points (default: 200, total ~ 4*N^2)")
|
||||
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)")
|
||||
parser.add_argument("--noise", type=float, default=0.0,
|
||||
help="Gaussian noise RMS in nm (default: 0)")
|
||||
parser.add_argument("--grid-type", choices=["cartesian", "scattered"],
|
||||
default="cartesian",
|
||||
help="Grid type (default: cartesian)")
|
||||
parser.add_argument("--unit", choices=["nm", "mm"], default="nm",
|
||||
help="OPD output unit (default: nm)")
|
||||
help="Gaussian noise RMS in nm")
|
||||
parser.add_argument("--lateral", action="store_true",
|
||||
help="Include small lateral DX/DY displacements")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("Synthetic WFE Surface Generator")
|
||||
print("Zernike Pipeline Validation — GigaBIT M1")
|
||||
print("Zernike Pipeline Validation — GigaBIT")
|
||||
print("=" * 60)
|
||||
print(f" Mirror diameter: {args.diameter} mm")
|
||||
print(f" Inner radius: {args.inner_radius} mm")
|
||||
print(f" Grid points: ~{(args.grid_points*2)**2}")
|
||||
|
||||
if args.suite:
|
||||
generate_test_suite(
|
||||
args.output_dir,
|
||||
args.diameter,
|
||||
args.inner_radius,
|
||||
args.grid_points,
|
||||
inject_csv=args.inject,
|
||||
diameter_mm=args.diameter,
|
||||
inner_radius_mm=args.inner_radius,
|
||||
surface_type=args.surface,
|
||||
roc_mm=args.roc,
|
||||
)
|
||||
else:
|
||||
# Determine coefficients
|
||||
@@ -497,25 +603,36 @@ Examples:
|
||||
print("\nERROR: Specify --zernike, --preset, or --suite")
|
||||
sys.exit(1)
|
||||
|
||||
# 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")
|
||||
|
||||
print(f"\n Coefficients:")
|
||||
for j, amp in sorted(coeffs.items()):
|
||||
print(f" Z{j:2d} ({zernike_name(j):20s}): {amp:8.2f} nm")
|
||||
|
||||
# Generate
|
||||
x, y, _ = generate_grid(args.diameter, args.inner_radius,
|
||||
args.grid_points, args.grid_type)
|
||||
opd, meta = synthesize_surface(x, y, coeffs, args.diameter,
|
||||
noise_rms_nm=args.noise)
|
||||
dx, dy, dz, meta = synthesize_displacements(
|
||||
x_m, y_m, coeffs, diameter_mm,
|
||||
noise_rms_nm=args.noise,
|
||||
include_lateral=args.lateral,
|
||||
)
|
||||
|
||||
print(f"\n Points in aperture: {len(x)}")
|
||||
print(f" RMS (clean): {meta['rms_nm_clean']:.3f} nm")
|
||||
print(f"\n RMS (clean): {meta['rms_nm_clean']:.3f} nm")
|
||||
if args.noise > 0:
|
||||
print(f" RMS (noisy): {meta['rms_nm_with_noise']:.3f} nm")
|
||||
|
||||
# Write outputs
|
||||
print(f"\n Output unit: {args.unit}")
|
||||
write_csv_opd(args.output, x, y, opd, opd_unit=args.unit)
|
||||
|
||||
write_csv_fea(args.output, x_m, y_m, z_m, dx, dy, dz)
|
||||
meta_path = Path(args.output).with_suffix('.json')
|
||||
write_metadata(str(meta_path), meta)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user