Major improvements to Zernike WFE visualization: - Add ZernikeDashboardInsight: Unified dashboard with all orientations (40°, 60°, 90°) on one page with light theme and executive summary - Add OPD method toggle: Switch between Standard (Z-only) and OPD (X,Y,Z) methods in ZernikeWFEInsight with interactive buttons - Add lateral displacement maps: Visualize X,Y displacement for each orientation - Add displacement component views: Toggle between WFE, ΔX, ΔY, ΔZ in relative views - Add metrics comparison table showing both methods side-by-side New extractors: - extract_zernike_figure.py: ZernikeOPDExtractor using BDF geometry interpolation - extract_zernike_opd.py: Parabola-based OPD with focal length Key finding: OPD method gives 8-11% higher WFE values than Standard method (more conservative/accurate for surfaces with lateral displacement under gravity) Documentation updates: - SYS_12: Added E22 ZernikeOPD as recommended method - SYS_16: Added ZernikeDashboard, updated ZernikeWFE with OPD features - Cheatsheet: Added Zernike method comparison table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
209 lines
7.6 KiB
Python
209 lines
7.6 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Compare all Zernike extraction methods on M1 Mirror data.
|
|
|
|
Methods compared:
|
|
1. Standard - Z-displacement only at original (x,y)
|
|
2. Parabola OPD - Uses parabola approximation with prescription focal length
|
|
3. Figure OPD - Uses actual figure.dat geometry (most rigorous)
|
|
|
|
Run:
|
|
python tests/test_zernike_methods_comparison.py studies/M1_Mirror/m1_mirror_cost_reduction_V9
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
import numpy as np
|
|
|
|
|
|
def run_full_comparison(study_dir: Path):
|
|
"""Run comparison of all three Zernike methods."""
|
|
from optimization_engine.extractors.extract_zernike import ZernikeExtractor
|
|
from optimization_engine.extractors.extract_zernike_opd import ZernikeAnalyticExtractor
|
|
from optimization_engine.extractors.extract_zernike_figure import ZernikeOPDExtractor
|
|
|
|
# Find OP2 file
|
|
op2_files = list(study_dir.glob('3_results/best_design_archive/**/*.op2'))
|
|
if not op2_files:
|
|
op2_files = list(study_dir.glob('2_iterations/iter1/*.op2'))
|
|
if not op2_files:
|
|
raise FileNotFoundError(f"No OP2 file found in {study_dir}")
|
|
|
|
op2_file = op2_files[0]
|
|
|
|
# Figure file is optional - extractor will use BDF geometry filtered to OP2 nodes
|
|
figure_file = study_dir / '1_setup' / 'model' / 'figure.dat'
|
|
use_figure_file = figure_file.exists()
|
|
|
|
print("=" * 80)
|
|
print("ZERNIKE METHODS COMPARISON - M1 MIRROR")
|
|
print("=" * 80)
|
|
print(f"\nOP2 file: {op2_file.name}")
|
|
if use_figure_file:
|
|
print(f"Figure file: {figure_file.name}")
|
|
else:
|
|
print("Figure file: Not found (using BDF geometry filtered to OP2 nodes)")
|
|
|
|
# Initialize extractors
|
|
print("\nInitializing extractors...")
|
|
|
|
std_extractor = ZernikeExtractor(op2_file)
|
|
print(f" Standard: {len(std_extractor.node_geometry)} nodes in BDF")
|
|
|
|
analytic_extractor = ZernikeAnalyticExtractor(op2_file, focal_length=1445.0, concave=True)
|
|
print(f" Analytic (Parabola f=1445mm): {len(analytic_extractor.node_geometry)} nodes")
|
|
|
|
# OPD extractor - ALWAYS use BDF geometry filtered to OP2 nodes (RECOMMENDED)
|
|
# (figure.dat may have mismatched coordinates from different model state)
|
|
opd_extractor = ZernikeOPDExtractor(
|
|
op2_file,
|
|
figure_path=None # Force use of BDF geometry
|
|
)
|
|
print(f" OPD (BDF geometry): {len(opd_extractor.figure_geometry)} figure nodes")
|
|
|
|
print()
|
|
|
|
# Get available subcases
|
|
subcases = list(std_extractor.displacements.keys())
|
|
print(f"Available subcases: {subcases}")
|
|
|
|
# Results table header
|
|
print("\n" + "=" * 80)
|
|
print(f"{'Subcase':<10} {'Standard':<15} {'Analytic':<15} {'OPD':<15} {'Max Lat (um)':<12}")
|
|
print("-" * 80)
|
|
|
|
all_results = {}
|
|
|
|
for sc in subcases:
|
|
try:
|
|
# Extract with each method
|
|
std_res = std_extractor.extract_subcase(sc)
|
|
analytic_res = analytic_extractor.extract_subcase(sc)
|
|
opd_res = opd_extractor.extract_subcase(sc)
|
|
|
|
std_rms = std_res['filtered_rms_nm']
|
|
analytic_rms = analytic_res['filtered_rms_nm']
|
|
opd_rms = opd_res['filtered_rms_nm']
|
|
max_lat = opd_res.get('max_lateral_displacement_um', 0)
|
|
|
|
print(f"{sc:<10} {std_rms:<15.2f} {analytic_rms:<15.2f} {opd_rms:<15.2f} {max_lat:<12.3f}")
|
|
|
|
all_results[sc] = {
|
|
'standard': std_res,
|
|
'analytic': analytic_res,
|
|
'opd': opd_res
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"{sc:<10} ERROR: {e}")
|
|
|
|
print("-" * 80)
|
|
|
|
# Detailed comparison for each subcase
|
|
print("\n" + "=" * 80)
|
|
print("DETAILED COMPARISON")
|
|
print("=" * 80)
|
|
|
|
for sc, results in all_results.items():
|
|
print(f"\n{'-' * 40}")
|
|
print(f"SUBCASE {sc}")
|
|
print(f"{'-' * 40}")
|
|
|
|
std = results['standard']
|
|
analytic = results['analytic']
|
|
opd = results['opd']
|
|
|
|
print(f"\n{'Metric':<25} {'Standard':<15} {'Analytic':<15} {'OPD':<15}")
|
|
print("-" * 70)
|
|
|
|
# RMS metrics
|
|
print(f"{'Filtered RMS (nm)':<25} {std['filtered_rms_nm']:<15.2f} {analytic['filtered_rms_nm']:<15.2f} {opd['filtered_rms_nm']:<15.2f}")
|
|
print(f"{'Global RMS (nm)':<25} {std['global_rms_nm']:<15.2f} {analytic['global_rms_nm']:<15.2f} {opd['global_rms_nm']:<15.2f}")
|
|
|
|
# Aberrations
|
|
for aberr in ['defocus', 'astigmatism', 'coma', 'trefoil', 'spherical']:
|
|
key = f'{aberr}_nm'
|
|
if key in std and key in analytic and key in opd:
|
|
print(f"{aberr.capitalize():<25} {std[key]:<15.2f} {analytic[key]:<15.2f} {opd[key]:<15.2f}")
|
|
|
|
# Node count
|
|
print(f"{'Nodes':<25} {std['n_nodes']:<15} {analytic['n_nodes']:<15} {opd['n_nodes']:<15}")
|
|
|
|
# Lateral displacement (only for OPD methods)
|
|
print()
|
|
print(f"Lateral displacement (OPD method):")
|
|
print(f" Max: {opd.get('max_lateral_displacement_um', 0):.3f} um")
|
|
print(f" RMS: {opd.get('rms_lateral_displacement_um', 0):.3f} um")
|
|
print(f" Mean: {opd.get('mean_lateral_displacement_um', 0):.3f} um")
|
|
|
|
# Differences
|
|
print()
|
|
diff_std_opd = opd['filtered_rms_nm'] - std['filtered_rms_nm']
|
|
diff_analytic_opd = opd['filtered_rms_nm'] - analytic['filtered_rms_nm']
|
|
|
|
print(f"Difference from Standard:")
|
|
print(f" OPD: {diff_std_opd:+.2f} nm ({100*diff_std_opd/std['filtered_rms_nm']:+.1f}%)")
|
|
print(f" Analytic: {analytic['filtered_rms_nm'] - std['filtered_rms_nm']:+.2f} nm")
|
|
print()
|
|
print(f"Difference OPD vs Analytic: {diff_analytic_opd:+.2f} nm")
|
|
|
|
# Tracking WFE analysis
|
|
if '2' in all_results and '3' in all_results and '4' in all_results:
|
|
print("\n" + "=" * 80)
|
|
print("TRACKING WFE ANALYSIS (elevation changes)")
|
|
print("=" * 80)
|
|
|
|
for method_name, method_key in [('Standard', 'standard'), ('Analytic', 'analytic'), ('OPD', 'opd')]:
|
|
print(f"\n{method_name}:")
|
|
|
|
z20 = np.array(all_results['2'][method_key].get('coefficients', [0]*36))
|
|
z40 = np.array(all_results['3'][method_key].get('coefficients', [0]*36))
|
|
z60 = np.array(all_results['4'][method_key].get('coefficients', [0]*36))
|
|
|
|
if len(z20) > 4:
|
|
# Differential (J5+)
|
|
diff_40_20 = z40 - z20
|
|
diff_60_20 = z60 - z20
|
|
|
|
rms_40_20 = np.sqrt(np.sum(diff_40_20[4:]**2))
|
|
rms_60_20 = np.sqrt(np.sum(diff_60_20[4:]**2))
|
|
|
|
print(f" 40-20 deg tracking: {rms_40_20:.2f} nm RMS (J5+ filtered)")
|
|
print(f" 60-20 deg tracking: {rms_60_20:.2f} nm RMS (J5+ filtered)")
|
|
else:
|
|
print(f" (coefficients not available)")
|
|
|
|
print("\n" + "=" * 80)
|
|
print("SUMMARY")
|
|
print("=" * 80)
|
|
print("""
|
|
Key findings:
|
|
- Standard method: Uses Z-displacement only at original (x,y) - fast but ignores lateral shift
|
|
- Analytic method: Accounts for lateral shift using parabola formula (requires focal length)
|
|
- OPD method: Uses actual mesh geometry - MOST RIGOROUS, no shape assumption
|
|
|
|
Recommendation: Use OPD method (ZernikeOPDExtractor) for all mirror optimization.
|
|
The Analytic method is useful for comparison against theoretical parabola.
|
|
""")
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description='Compare Zernike extraction methods')
|
|
parser.add_argument('study_dir', type=str, help='Path to study directory')
|
|
args = parser.parse_args()
|
|
|
|
study_dir = Path(args.study_dir).resolve()
|
|
if not study_dir.exists():
|
|
print(f"Study directory not found: {study_dir}")
|
|
sys.exit(1)
|
|
|
|
run_full_comparison(study_dir)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|