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,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:
|
||||
|
||||
Reference in New Issue
Block a user