Files
Atomizer/tools/test_zernike_recentering.py

350 lines
13 KiB
Python
Raw Normal View History

"""
Test: Is recentering Z per subcase equivalent to removing piston from relative WFE?
Theory:
- Option A: Recenter each subcase, then subtract
WFE_rel_A = (dZ_20 - mean(dZ_20)) - (dZ_90 - mean(dZ_90))
- Option B: Subtract raw, then remove piston via Zernike J1
WFE_rel_B = dZ_20 - dZ_90
Then filter J1 from Zernike fit
Mathematically:
WFE_rel_A = dZ_20 - dZ_90 - mean(dZ_20) + mean(dZ_90)
= (dZ_20 - dZ_90) - (mean(dZ_20) - mean(dZ_90))
If nodes are identical: mean(dZ_20) - mean(dZ_90) = mean(dZ_20 - dZ_90)
So: WFE_rel_A = WFE_rel_B - mean(WFE_rel_B)
This should equal WFE_rel_B with J1 (piston) removed, BUT only if:
1. Piston Zernike Z1 = 1 (constant)
2. Sampling is uniform (or Z1 coefficient = mean for non-uniform)
For annular apertures and non-uniform mesh sampling, this might not be exact!
"""
import numpy as np
from pathlib import Path
import sys
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from optimization_engine.extractors.extract_zernike import (
ZernikeExtractor,
compute_zernike_coefficients,
compute_rms_metrics,
zernike_noll,
DEFAULT_N_MODES,
DEFAULT_FILTER_ORDERS,
)
def test_recentering_equivalence_synthetic():
"""Test with synthetic data where we control everything."""
print("=" * 70)
print("TEST 1: Synthetic Data")
print("=" * 70)
np.random.seed(42)
# Create annular aperture (like a telescope mirror)
n_points = 5000
r_inner = 0.3 # 30% central obscuration
r_outer = 1.0
# Generate random points in annulus
r = np.sqrt(np.random.uniform(r_inner**2, r_outer**2, n_points))
theta = np.random.uniform(0, 2*np.pi, n_points)
X = r * np.cos(theta) * 500 # Scale to 500mm radius
Y = r * np.sin(theta) * 500
# Simulate Z-displacements for two subcases (in mm, will convert to nm)
# Subcase 90: Some aberration pattern + piston
piston_90 = 0.001 # 1 micron mean displacement
dZ_90 = piston_90 + 0.0005 * (X**2 + Y**2) / 500**2 # Add some power
dZ_90 += 0.0002 * np.random.randn(n_points) # Add noise
# Subcase 20: Different aberration + different piston
piston_20 = 0.003 # 3 micron mean displacement
dZ_20 = piston_20 + 0.0008 * (X**2 + Y**2) / 500**2 # More power
dZ_20 += 0.0003 * X / 500 # Add some tilt
dZ_20 += 0.0002 * np.random.randn(n_points) # Add noise
# Convert to WFE in nm (2x for reflection, 1e6 for mm->nm)
wfe_factor = 2.0 * 1e6
WFE_90 = dZ_90 * wfe_factor
WFE_20 = dZ_20 * wfe_factor
print(f"\nInput data:")
print(f" Points: {n_points} (annular, r_inner={r_inner})")
print(f" Mean WFE_90: {np.mean(WFE_90):.2f} nm")
print(f" Mean WFE_20: {np.mean(WFE_20):.2f} nm")
print(f" Mean difference: {np.mean(WFE_20) - np.mean(WFE_90):.2f} nm")
# =========================================================================
# Option A: Recenter each subcase BEFORE subtraction
# =========================================================================
WFE_90_centered = WFE_90 - np.mean(WFE_90)
WFE_20_centered = WFE_20 - np.mean(WFE_20)
WFE_rel_A = WFE_20_centered - WFE_90_centered
# Fit Zernike to option A
coeffs_A, R_max_A = compute_zernike_coefficients(X, Y, WFE_rel_A, DEFAULT_N_MODES)
# Compute RMS metrics for option A
rms_A = compute_rms_metrics(X, Y, WFE_rel_A, DEFAULT_N_MODES, DEFAULT_FILTER_ORDERS)
print(f"\nOption A (recenter before subtraction):")
print(f" Mean WFE_rel_A: {np.mean(WFE_rel_A):.6f} nm (should be ~0)")
print(f" J1 (piston) coefficient: {coeffs_A[0]:.6f} nm")
print(f" Global RMS: {rms_A['global_rms_nm']:.2f} nm")
print(f" Filtered RMS (J1-J4 removed): {rms_A['filtered_rms_nm']:.2f} nm")
# =========================================================================
# Option B: Subtract raw, then analyze (current implementation)
# =========================================================================
WFE_rel_B = WFE_20 - WFE_90 # No recentering
# Fit Zernike to option B
coeffs_B, R_max_B = compute_zernike_coefficients(X, Y, WFE_rel_B, DEFAULT_N_MODES)
# Compute RMS metrics for option B
rms_B = compute_rms_metrics(X, Y, WFE_rel_B, DEFAULT_N_MODES, DEFAULT_FILTER_ORDERS)
print(f"\nOption B (current: subtract raw, filter J1-J4 after):")
print(f" Mean WFE_rel_B: {np.mean(WFE_rel_B):.2f} nm")
print(f" J1 (piston) coefficient: {coeffs_B[0]:.2f} nm")
print(f" Global RMS: {rms_B['global_rms_nm']:.2f} nm")
print(f" Filtered RMS (J1-J4 removed): {rms_B['filtered_rms_nm']:.2f} nm")
# =========================================================================
# Compare
# =========================================================================
print(f"\n" + "=" * 70)
print("COMPARISON")
print("=" * 70)
# Check if J1 coefficient in B equals the mean
j1_vs_mean_diff = abs(coeffs_B[0] - np.mean(WFE_rel_B))
print(f"\n1. Does J1 = mean(WFE)?")
print(f" J1 coefficient: {coeffs_B[0]:.6f} nm")
print(f" Mean of WFE: {np.mean(WFE_rel_B):.6f} nm")
print(f" Difference: {j1_vs_mean_diff:.6f} nm")
print(f" --> {'YES (negligible diff)' if j1_vs_mean_diff < 0.01 else 'NO (significant diff!)'}")
# Check if filtered RMS is the same
filtered_rms_diff = abs(rms_A['filtered_rms_nm'] - rms_B['filtered_rms_nm'])
print(f"\n2. Is filtered RMS the same?")
print(f" Option A filtered RMS: {rms_A['filtered_rms_nm']:.6f} nm")
print(f" Option B filtered RMS: {rms_B['filtered_rms_nm']:.6f} nm")
print(f" Difference: {filtered_rms_diff:.6f} nm")
print(f" --> {'EQUIVALENT' if filtered_rms_diff < 0.01 else 'NOT EQUIVALENT!'}")
# Check all coefficients (J2 onwards should be identical)
coeff_diffs = np.abs(coeffs_A[1:] - coeffs_B[1:]) # Skip J1
max_coeff_diff = np.max(coeff_diffs)
print(f"\n3. Are non-piston coefficients (J2+) identical?")
print(f" Max difference in J2-J{DEFAULT_N_MODES}: {max_coeff_diff:.6f} nm")
print(f" --> {'YES' if max_coeff_diff < 0.01 else 'NO!'}")
# The key insight: for non-uniform sampling, J1 might not equal mean exactly
# Let's check how Z1 (piston polynomial) behaves
x_c = X - np.mean(X)
y_c = Y - np.mean(Y)
R_max = np.max(np.hypot(x_c, y_c))
r_norm = np.hypot(x_c / R_max, y_c / R_max)
theta_norm = np.arctan2(y_c, x_c)
Z1 = zernike_noll(1, r_norm, theta_norm) # Piston polynomial
print(f"\n4. Piston polynomial Z1 statistics:")
print(f" Z1 should be constant = 1 for all points")
print(f" Mean(Z1): {np.mean(Z1):.6f}")
print(f" Std(Z1): {np.std(Z1):.6f}")
print(f" Min(Z1): {np.min(Z1):.6f}")
print(f" Max(Z1): {np.max(Z1):.6f}")
return filtered_rms_diff < 0.01
def test_recentering_with_real_data():
"""Test with real OP2 data if available."""
print("\n" + "=" * 70)
print("TEST 2: Real Data (if available)")
print("=" * 70)
# Look for a real study with OP2 data
studies_dir = Path(__file__).parent.parent / "studies"
op2_files = list(studies_dir.rglob("*.op2"))
if not op2_files:
print(" No OP2 files found in studies directory. Skipping real data test.")
return None
# Use the first one found
op2_file = op2_files[0]
print(f"\n Using: {op2_file.relative_to(studies_dir.parent)}")
try:
extractor = ZernikeExtractor(op2_file)
subcases = list(extractor.displacements.keys())
print(f" Available subcases: {subcases}")
if len(subcases) < 2:
print(" Need at least 2 subcases for relative comparison. Skipping.")
return None
# Pick two subcases
ref_subcase = subcases[0]
target_subcase = subcases[1]
print(f" Comparing: {target_subcase} vs {ref_subcase}")
# Get raw data
X_t, Y_t, WFE_t = extractor._build_dataframe(target_subcase)
X_r, Y_r, WFE_r = extractor._build_dataframe(ref_subcase)
# Build node matching (same logic as extract_relative)
target_data = extractor.displacements[target_subcase]
ref_data = extractor.displacements[ref_subcase]
ref_node_to_idx = {
int(nid): i for i, nid in enumerate(ref_data['node_ids'])
}
X_common, Y_common = [], []
WFE_target_common, WFE_ref_common = [], []
for i, nid in enumerate(target_data['node_ids']):
nid = int(nid)
if nid not in ref_node_to_idx:
continue
ref_idx = ref_node_to_idx[nid]
geo = extractor.node_geometry.get(nid)
if geo is None:
continue
X_common.append(geo[0])
Y_common.append(geo[1])
WFE_target_common.append(target_data['disp'][i, 2] * extractor.wfe_factor)
WFE_ref_common.append(ref_data['disp'][ref_idx, 2] * extractor.wfe_factor)
X = np.array(X_common)
Y = np.array(Y_common)
WFE_target = np.array(WFE_target_common)
WFE_ref = np.array(WFE_ref_common)
print(f" Common nodes: {len(X)}")
print(f" Mean WFE target ({target_subcase}): {np.mean(WFE_target):.2f} nm")
print(f" Mean WFE ref ({ref_subcase}): {np.mean(WFE_ref):.2f} nm")
# Option A: Recenter before
WFE_target_centered = WFE_target - np.mean(WFE_target)
WFE_ref_centered = WFE_ref - np.mean(WFE_ref)
WFE_rel_A = WFE_target_centered - WFE_ref_centered
rms_A = compute_rms_metrics(X, Y, WFE_rel_A, DEFAULT_N_MODES, DEFAULT_FILTER_ORDERS)
# Option B: Current (no recentering)
WFE_rel_B = WFE_target - WFE_ref
rms_B = compute_rms_metrics(X, Y, WFE_rel_B, DEFAULT_N_MODES, DEFAULT_FILTER_ORDERS)
print(f"\n Option A (recenter before):")
print(f" Filtered RMS: {rms_A['filtered_rms_nm']:.4f} nm")
print(f"\n Option B (current, filter after):")
print(f" Filtered RMS: {rms_B['filtered_rms_nm']:.4f} nm")
diff = abs(rms_A['filtered_rms_nm'] - rms_B['filtered_rms_nm'])
pct_diff = 100 * diff / rms_B['filtered_rms_nm'] if rms_B['filtered_rms_nm'] > 0 else 0
print(f"\n Difference: {diff:.4f} nm ({pct_diff:.4f}%)")
print(f" --> {'EQUIVALENT' if pct_diff < 0.1 else 'NOT EQUIVALENT!'}")
return pct_diff < 0.1
except Exception as e:
print(f" Error: {e}")
import traceback
traceback.print_exc()
return None
def test_annular_zernike_piston():
"""
Specifically test whether J1 = mean for annular apertures.
For a filled circular aperture with uniform sampling, J1 = mean exactly.
For annular apertures or non-uniform sampling, this may not hold!
"""
print("\n" + "=" * 70)
print("TEST 3: Annular Aperture - Does J1 = mean?")
print("=" * 70)
np.random.seed(123)
# Test different inner radii
for r_inner in [0.0, 0.2, 0.4, 0.6]:
n_points = 10000
r_outer = 1.0
# Generate points in annulus
if r_inner > 0:
r = np.sqrt(np.random.uniform(r_inner**2, r_outer**2, n_points))
else:
r = np.sqrt(np.random.uniform(0, r_outer**2, n_points))
theta = np.random.uniform(0, 2*np.pi, n_points)
X = r * np.cos(theta) * 500
Y = r * np.sin(theta) * 500
# Create WFE with known piston
true_piston = 1000.0 # nm
WFE = true_piston + 100 * np.random.randn(n_points)
# Fit Zernike
coeffs, _ = compute_zernike_coefficients(X, Y, WFE, DEFAULT_N_MODES)
j1_coeff = coeffs[0]
wfe_mean = np.mean(WFE)
diff = abs(j1_coeff - wfe_mean)
print(f"\n r_inner = {r_inner}:")
print(f" True piston: {true_piston:.2f} nm")
print(f" Mean(WFE): {wfe_mean:.2f} nm")
print(f" J1 coefficient: {j1_coeff:.2f} nm")
print(f" |J1 - mean|: {diff:.4f} nm")
print(f" --> {'J1 ≈ mean' if diff < 1.0 else 'J1 ≠ mean!'}")
if __name__ == "__main__":
print("\n" + "=" * 70)
print("ZERNIKE RECENTERING EQUIVALENCE TEST")
print("=" * 70)
print("\nQuestion: Is recentering Z per subcase equivalent to")
print(" removing piston (J1) from relative WFE?")
print("=" * 70)
# Run tests
test1_passed = test_recentering_equivalence_synthetic()
test2_passed = test_recentering_with_real_data()
test_annular_zernike_piston()
# Summary
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
print(f"\nSynthetic data test: {'PASSED' if test1_passed else 'FAILED'}")
if test2_passed is not None:
print(f"Real data test: {'PASSED' if test2_passed else 'FAILED'}")
else:
print("Real data test: SKIPPED")
print("\nConclusion:")
if test1_passed:
print(" For standard Zernike on circular/annular apertures,")
print(" recentering Z before subtraction IS equivalent to")
print(" filtering J1 (piston) after Zernike fit.")
print("\n The current implementation is correct!")
else:
print(" WARNING: Recentering and J1 filtering are NOT equivalent!")
print(" Consider updating the extractor to recenter before subtraction.")