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,11 +3,11 @@
Zernike Round-Trip Validator
=============================
Reads a synthetic WFE CSV + its truth JSON, fits Zernike coefficients,
and compares recovered vs input coefficients.
Reads a synthetic WFE CSV (FEA format: X,Y,Z,DX,DY,DZ) + its truth JSON,
fits Zernike coefficients from DZ, and compares recovered vs input.
This validates that the Zernike fitting math itself is correct by
doing a generate → fit → compare round-trip.
This validates that the Zernike fitting math is correct by doing a
generate → fit → compare round-trip.
Usage:
# Single file
@@ -21,6 +21,7 @@ Created: 2026-03-09
"""
import sys
import csv
import json
import argparse
from pathlib import Path
@@ -86,28 +87,67 @@ def zernike_name(j):
return names.get((n, m), f"Z({n},{m:+d})")
# ============================================================================
# CSV Reading (FEA format)
# ============================================================================
def read_fea_csv(csv_path: str):
"""
Read FEA-format CSV with columns: (index), X, Y, Z, DX, DY, DZ.
All values in meters.
Returns:
x_m, y_m, z_m, dx_m, dy_m, dz_m: arrays
"""
x, y, z, dx, dy, dz = [], [], [], [], [], []
with open(csv_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))
# ============================================================================
# Zernike Fitting (Least Squares)
# ============================================================================
def fit_zernike(x_mm, y_mm, opd_nm, diameter_mm=1200.0, n_modes=50):
def fit_zernike(x_m, y_m, dz_m, diameter_mm=None, n_modes=50):
"""
Fit Zernike coefficients to OPD data via least-squares.
Fit Zernike coefficients to DZ displacement data.
Args:
x_m, y_m: Node positions in meters
dz_m: Z-displacement in meters
diameter_mm: Mirror diameter (auto-detected if None)
n_modes: Number of Zernike modes to fit
Returns:
coefficients: array of shape (n_modes,), Noll-indexed from j=1
coefficients_nm: array of shape (n_modes,), amplitudes in nm
"""
outer_radius = diameter_mm / 2.0
r_norm = np.sqrt(x_mm**2 + y_mm**2) / outer_radius
theta = np.arctan2(y_mm, x_mm)
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
r_norm = np.sqrt(x_m**2 + y_m**2) / outer_r_m
theta = np.arctan2(y_m, x_m)
# Convert DZ to nm
dz_nm = dz_m * 1e9
# Build Zernike basis matrix
Z = np.zeros((len(x_mm), n_modes))
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)
# Least-squares fit
coeffs, residuals, rank, sv = lstsq(Z, opd_nm, rcond=None)
coeffs, residuals, rank, sv = lstsq(Z, dz_nm, rcond=None)
return coeffs
@@ -116,31 +156,24 @@ def fit_zernike(x_mm, y_mm, opd_nm, diameter_mm=1200.0, n_modes=50):
# Validation
# ============================================================================
def validate_file(csv_path: str, n_modes: int = 50, diameter_mm: float = 1200.0,
def validate_file(csv_path: str, n_modes: int = 50, diameter_mm: float = None,
tolerance_nm: float = 0.5, verbose: bool = True):
"""
Validate a single synthetic WFE file.
Returns:
passed: bool
results: dict with comparison details
results: dict
"""
csv_path = Path(csv_path)
truth_path = csv_path.with_name(csv_path.stem + "_truth.json")
if not truth_path.exists():
# Try removing any suffix before _truth
base = csv_path.stem
truth_path = csv_path.with_name(base + "_truth.json")
if not truth_path.exists():
print(f" WARNING: No truth file found for {csv_path.name}")
return None, None
print(f" WARNING: No truth file found for {csv_path.name}")
return None, None
# Load CSV
data = np.loadtxt(csv_path, delimiter=",", skiprows=1)
x_mm = data[:, 0]
y_mm = data[:, 1]
opd_nm = data[:, 2]
# Load CSV (FEA format)
x_m, y_m, z_m, dx_m, dy_m, dz_m = read_fea_csv(csv_path)
# Load truth
with open(truth_path) as f:
@@ -148,8 +181,12 @@ def validate_file(csv_path: str, n_modes: int = 50, diameter_mm: float = 1200.0,
input_coeffs = {int(k): v for k, v in truth["input_coefficients"].items()}
# Fit Zernike
recovered = fit_zernike(x_mm, y_mm, opd_nm, diameter_mm, n_modes)
# Use diameter from truth if available
if diameter_mm is None:
diameter_mm = truth.get("diameter_mm")
# Fit Zernike from DZ
recovered = fit_zernike(x_m, y_m, dz_m, diameter_mm, n_modes)
# Compare
max_error = 0.0
@@ -177,7 +214,6 @@ def validate_file(csv_path: str, n_modes: int = 50, diameter_mm: float = 1200.0,
"passed": mode_passed,
}
# Only print modes with non-zero input or significant recovery
if verbose and (abs(input_val) > 0.01 or abs(recovered_val) > tolerance_nm):
status = "" if mode_passed else ""
print(f" Z{j:>4d} {zernike_name(j):>20} {input_val:>10.3f} {recovered_val:>14.3f} {error:>10.6f} {status:>8}")
@@ -185,7 +221,7 @@ def validate_file(csv_path: str, n_modes: int = 50, diameter_mm: float = 1200.0,
results["max_error_nm"] = float(max_error)
results["all_passed"] = all_passed
results["tolerance_nm"] = tolerance_nm
results["n_points"] = len(x_mm)
results["n_points"] = len(x_m)
if verbose:
print(f"\n Max error: {max_error:.6f} nm")
@@ -238,12 +274,13 @@ def validate_suite(suite_dir: str, n_modes: int = 50, tolerance_nm: float = 0.5)
def main():
parser = argparse.ArgumentParser(description="Validate Zernike round-trip accuracy")
parser.add_argument("input", nargs="?", help="CSV file or --suite directory")
parser.add_argument("input", nargs="?", help="CSV file or use --suite")
parser.add_argument("--suite", type=str, help="Validate all CSVs in directory")
parser.add_argument("--n-modes", type=int, default=50)
parser.add_argument("--tolerance", type=float, default=0.5,
help="Max acceptable coefficient error in nm (default: 0.5)")
parser.add_argument("--diameter", type=float, default=1200.0)
parser.add_argument("--diameter", type=float, default=None,
help="Mirror diameter in mm (auto-detected if not set)")
args = parser.parse_args()
if args.suite: