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>
253 lines
8.4 KiB
Python
253 lines
8.4 KiB
Python
"""
|
|
Compare V8 Best Candidate: OPD Method vs Standard Z-Only Method
|
|
|
|
This script extracts WFE using both methods and compares the results
|
|
to quantify the difference between using full X,Y,Z displacement (OPD)
|
|
vs just Z displacement (Standard).
|
|
"""
|
|
import sys
|
|
sys.path.insert(0, '.')
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
import json
|
|
import numpy as np
|
|
|
|
# Find V8 best trial
|
|
db_path = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V8/3_results/study.db')
|
|
print(f"V8 Database: {db_path}")
|
|
print(f"Exists: {db_path.exists()}")
|
|
|
|
if not db_path.exists():
|
|
print("ERROR: V8 database not found!")
|
|
sys.exit(1)
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
|
|
# Get all completed trials with their objectives
|
|
c.execute('''
|
|
SELECT t.trial_id
|
|
FROM trials t
|
|
WHERE t.state = 'COMPLETE'
|
|
ORDER BY t.trial_id
|
|
''')
|
|
completed_ids = [row[0] for row in c.fetchall()]
|
|
print(f"Completed trials: {len(completed_ids)}")
|
|
|
|
# Build trial data and find best
|
|
trials = []
|
|
for tid in completed_ids:
|
|
c.execute('''
|
|
SELECT key, value_json
|
|
FROM trial_user_attributes
|
|
WHERE trial_id = ?
|
|
''', (tid,))
|
|
attrs = {row[0]: json.loads(row[1]) for row in c.fetchall()}
|
|
|
|
# V8 used different objective names - check what's available
|
|
rms_40 = attrs.get('rel_filtered_rms_40_vs_20', attrs.get('filtered_rms_40_20', None))
|
|
rms_60 = attrs.get('rel_filtered_rms_60_vs_20', attrs.get('filtered_rms_60_20', None))
|
|
mfg_90 = attrs.get('mfg_90_optician_workload', attrs.get('optician_workload_90', None))
|
|
ws = attrs.get('weighted_sum', None)
|
|
|
|
if rms_40 is not None:
|
|
trials.append({
|
|
'id': tid,
|
|
'rms_40': rms_40,
|
|
'rms_60': rms_60 if rms_60 else 999,
|
|
'mfg_90': mfg_90 if mfg_90 else 999,
|
|
'ws': ws if ws else 999,
|
|
})
|
|
|
|
conn.close()
|
|
|
|
if not trials:
|
|
# Check what keys are available
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute('SELECT DISTINCT key FROM trial_user_attributes LIMIT 20')
|
|
keys = [row[0] for row in c.fetchall()]
|
|
print(f"\nAvailable attribute keys: {keys}")
|
|
conn.close()
|
|
print("ERROR: No trials found with expected objective names!")
|
|
sys.exit(1)
|
|
|
|
# Calculate weighted sum if not present
|
|
for t in trials:
|
|
if t['ws'] == 999:
|
|
w40 = 100 / 4.0
|
|
w60 = 50 / 10.0
|
|
w90 = 20 / 20.0
|
|
t['ws'] = w40 * t['rms_40'] + w60 * t['rms_60'] + w90 * t['mfg_90']
|
|
|
|
# Find best
|
|
trials.sort(key=lambda x: x['ws'])
|
|
best = trials[0]
|
|
|
|
print(f"\n{'='*70}")
|
|
print("V8 Best Trial Summary")
|
|
print('='*70)
|
|
print(f"Best Trial: #{best['id']}")
|
|
print(f" 40-20 RMS: {best['rms_40']:.2f} nm")
|
|
print(f" 60-20 RMS: {best['rms_60']:.2f} nm")
|
|
print(f" MFG 90: {best['mfg_90']:.2f} nm")
|
|
print(f" WS: {best['ws']:.1f}")
|
|
|
|
# Now compare both extraction methods on this trial
|
|
iter_path = Path(f'studies/M1_Mirror/m1_mirror_cost_reduction_V8/2_iterations/iter{best["id"]}')
|
|
op2_path = iter_path / 'assy_m1_assyfem1_sim1-solution_1.op2'
|
|
geo_path = iter_path / 'assy_m1_assyfem1_sim1-solution_1.dat'
|
|
|
|
print(f"\nIteration path: {iter_path}")
|
|
print(f"OP2 exists: {op2_path.exists()}")
|
|
print(f"Geometry exists: {geo_path.exists()}")
|
|
|
|
if not op2_path.exists():
|
|
print("ERROR: OP2 file not found for best trial!")
|
|
sys.exit(1)
|
|
|
|
print(f"\n{'='*70}")
|
|
print("Comparing Zernike Methods: OPD vs Standard")
|
|
print('='*70)
|
|
|
|
# Import extractors
|
|
from optimization_engine.extractors.extract_zernike_figure import ZernikeOPDExtractor
|
|
from optimization_engine.extractors.extract_zernike import ZernikeExtractor
|
|
|
|
# Standard method (Z-only)
|
|
print("\n1. STANDARD METHOD (Z-only displacement)")
|
|
print("-" * 50)
|
|
try:
|
|
std_extractor = ZernikeExtractor(
|
|
op2_path,
|
|
bdf_path=geo_path,
|
|
n_modes=50,
|
|
filter_orders=4
|
|
)
|
|
|
|
# Extract relative WFE
|
|
std_40_20 = std_extractor.extract_relative(target_subcase='3', reference_subcase='2')
|
|
std_60_20 = std_extractor.extract_relative(target_subcase='4', reference_subcase='2')
|
|
|
|
# MFG uses J1-J3 filter - need new extractor instance
|
|
std_extractor_mfg = ZernikeExtractor(
|
|
op2_path,
|
|
bdf_path=geo_path,
|
|
n_modes=50,
|
|
filter_orders=3 # J1-J3 for manufacturing
|
|
)
|
|
std_90 = std_extractor_mfg.extract_subcase(subcase_label='1')
|
|
|
|
print(f" 40-20 Relative RMS: {std_40_20['relative_filtered_rms_nm']:.2f} nm")
|
|
print(f" 60-20 Relative RMS: {std_60_20['relative_filtered_rms_nm']:.2f} nm")
|
|
print(f" 90 MFG (J1-J3): {std_90['filtered_rms_nm']:.2f} nm")
|
|
|
|
std_results = {
|
|
'40_20': std_40_20['relative_filtered_rms_nm'],
|
|
'60_20': std_60_20['relative_filtered_rms_nm'],
|
|
'90_mfg': std_90['filtered_rms_nm'],
|
|
}
|
|
except Exception as e:
|
|
print(f" ERROR: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
std_results = None
|
|
|
|
# OPD method (X,Y,Z displacement with mesh interpolation)
|
|
print("\n2. OPD METHOD (X,Y,Z displacement with mesh interpolation)")
|
|
print("-" * 50)
|
|
try:
|
|
opd_extractor = ZernikeOPDExtractor(
|
|
op2_path,
|
|
bdf_path=geo_path,
|
|
n_modes=50,
|
|
filter_orders=4
|
|
)
|
|
|
|
# Extract relative WFE using OPD method
|
|
opd_40_20 = opd_extractor.extract_relative(target_subcase='3', reference_subcase='2')
|
|
opd_60_20 = opd_extractor.extract_relative(target_subcase='4', reference_subcase='2')
|
|
|
|
# MFG uses J1-J3 filter
|
|
opd_extractor_mfg = ZernikeOPDExtractor(
|
|
op2_path,
|
|
bdf_path=geo_path,
|
|
n_modes=50,
|
|
filter_orders=3 # J1-J3 for manufacturing
|
|
)
|
|
opd_90 = opd_extractor_mfg.extract_subcase(subcase_label='1')
|
|
|
|
print(f" 40-20 Relative RMS: {opd_40_20['relative_filtered_rms_nm']:.2f} nm")
|
|
print(f" 60-20 Relative RMS: {opd_60_20['relative_filtered_rms_nm']:.2f} nm")
|
|
print(f" 90 MFG (J1-J3): {opd_90['filtered_rms_nm']:.2f} nm")
|
|
|
|
# Also get lateral displacement info
|
|
print(f"\n Lateral Displacement (40° vs 20°):")
|
|
print(f" Max: {opd_40_20.get('max_lateral_displacement_um', 'N/A')} µm")
|
|
print(f" RMS: {opd_40_20.get('rms_lateral_displacement_um', 'N/A')} µm")
|
|
|
|
opd_results = {
|
|
'40_20': opd_40_20['relative_filtered_rms_nm'],
|
|
'60_20': opd_60_20['relative_filtered_rms_nm'],
|
|
'90_mfg': opd_90['filtered_rms_nm'],
|
|
'lateral_max': opd_40_20.get('max_lateral_displacement_um', 0),
|
|
'lateral_rms': opd_40_20.get('rms_lateral_displacement_um', 0),
|
|
}
|
|
except Exception as e:
|
|
print(f" ERROR: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
opd_results = None
|
|
|
|
# Comparison
|
|
if std_results and opd_results:
|
|
print(f"\n{'='*70}")
|
|
print("COMPARISON: OPD vs Standard Method")
|
|
print('='*70)
|
|
|
|
print(f"\n{'Metric':<25} {'Standard':<12} {'OPD':<12} {'Delta':<12} {'Delta %':<10}")
|
|
print("-" * 70)
|
|
|
|
for key, label in [('40_20', '40-20 RMS (nm)'), ('60_20', '60-20 RMS (nm)'), ('90_mfg', '90 MFG (nm)')]:
|
|
std_val = std_results[key]
|
|
opd_val = opd_results[key]
|
|
delta = opd_val - std_val
|
|
delta_pct = 100 * delta / std_val if std_val > 0 else 0
|
|
|
|
print(f"{label:<25} {std_val:<12.2f} {opd_val:<12.2f} {delta:+<12.2f} {delta_pct:+.1f}%")
|
|
|
|
print(f"\n{'='*70}")
|
|
print("INTERPRETATION")
|
|
print('='*70)
|
|
|
|
delta_40 = opd_results['40_20'] - std_results['40_20']
|
|
delta_60 = opd_results['60_20'] - std_results['60_20']
|
|
|
|
print(f"""
|
|
The OPD method accounts for lateral (X,Y) displacement when computing WFE.
|
|
|
|
For telescope mirrors with lateral supports:
|
|
- Gravity causes the mirror to shift laterally (X,Y) as well as sag (Z)
|
|
- The Standard method ignores this lateral shift
|
|
- The OPD method interpolates the ideal surface at deformed (x+dx, y+dy) positions
|
|
|
|
Key observations:
|
|
- 40-20 difference: {delta_40:+.2f} nm ({100*delta_40/std_results['40_20']:+.1f}%)
|
|
- 60-20 difference: {delta_60:+.2f} nm ({100*delta_60/std_results['60_20']:+.1f}%)
|
|
- Lateral displacement: Max {opd_results['lateral_max']:.3f} µm, RMS {opd_results['lateral_rms']:.3f} µm
|
|
|
|
Significance:
|
|
""")
|
|
|
|
if abs(delta_40) < 0.5 and abs(delta_60) < 0.5:
|
|
print(" -> SMALL DIFFERENCE: For this design, lateral displacement is minimal.")
|
|
print(" Both methods give similar results.")
|
|
else:
|
|
print(" -> SIGNIFICANT DIFFERENCE: Lateral displacement affects WFE computation.")
|
|
print(" OPD method is more physically accurate for this geometry.")
|
|
|
|
if opd_results['lateral_rms'] > 0.1:
|
|
print(f"\n WARNING: Lateral RMS {opd_results['lateral_rms']:.3f} µm is notable.")
|
|
print(" OPD method recommended for accurate optimization.")
|