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:
2026-03-09 15:56:23 +00:00
parent f9373bee99
commit 4146e9d8f1
40 changed files with 6865 additions and 2142012 deletions

View File

@@ -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)