Documentation: - Add docs/06_PHYSICS/ with Zernike fundamentals and OPD method docs - Add docs/guides/CMA-ES_EXPLAINED.md optimization guide - Update CLAUDE.md and ATOMIZER_CONTEXT.md with current architecture - Update OP_01_CREATE_STUDY protocol Planning: - Add DYNAMIC_RESPONSE plans for random vibration/PSD support - Add OPTIMIZATION_ENGINE_MIGRATION_PLAN for code reorganization Insights System: - Update design_space, modal_analysis, stress_field, thermal_field insights - Improve error handling and data validation NX Journals: - Add analyze_wfe_zernike.py for Zernike WFE analysis - Add capture_study_images.py for automated screenshots - Add extract_expressions.py and introspect_part.py utilities - Add user_generated_journals/journal_top_view_image_taking.py Tests & Tools: - Add comprehensive Zernike OPD test suite - Add audit_v10 tests for WFE validation - Add tools for Pareto graphs and mirror data extraction - Add migrate_studies_to_topics.py utility Knowledge Base: - Initialize LAC (Learning Atomizer Core) with failure/success patterns Dashboard: - Update Setup.tsx and launch_dashboard.py - Add restart-dev.bat helper script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Test ZernikeOPDExtractor with validated M1 Mirror optical prescription.
|
|
|
|
Compares:
|
|
1. Standard Zernike (Z-displacement only at original x,y)
|
|
2. OPD Zernike with auto-estimated focal length
|
|
3. OPD Zernike with correct focal length (1445 mm from prescription)
|
|
|
|
M1 Mirror Optical Prescription:
|
|
- Radius of Curvature: 2890 ± 3 mm
|
|
- Conic Constant: -0.987 ± 0.001 (near-parabolic)
|
|
- Clear Aperture: 1202 mm
|
|
- Central Bore: 271.56 mm
|
|
- Focal Length: 1445 mm (R/2)
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
# Add project root to path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
import numpy as np
|
|
|
|
|
|
def run_comparison(op2_path: Path):
|
|
"""Run comparison between standard and OPD Zernike methods."""
|
|
from optimization_engine.extractors.extract_zernike_opd import ZernikeOPDExtractor
|
|
from optimization_engine.extractors.extract_zernike_wfe import ZernikeExtractor
|
|
|
|
print("=" * 70)
|
|
print("ZERNIKE METHOD COMPARISON WITH VALIDATED PRESCRIPTION")
|
|
print("=" * 70)
|
|
print(f"\nOP2 file: {op2_path.name}")
|
|
print(f"Optical prescription focal length: 1445 mm")
|
|
print()
|
|
|
|
# 1. Standard Zernike (Z-displacement only)
|
|
print("1. STANDARD ZERNIKE (Z-displacement at original x,y)")
|
|
print("-" * 50)
|
|
try:
|
|
std_extractor = ZernikeExtractor(op2_path)
|
|
std_results = std_extractor.extract_all_subcases()
|
|
|
|
for sc, data in std_results.items():
|
|
coeffs = data['coefficients']
|
|
rms = data['rms_wfe_nm']
|
|
print(f" Subcase {sc}: RMS WFE = {rms:.2f} nm")
|
|
except Exception as e:
|
|
print(f" Error: {e}")
|
|
std_results = None
|
|
print()
|
|
|
|
# 2. OPD Zernike with auto-estimated focal length
|
|
print("2. OPD ZERNIKE (auto-estimated focal length)")
|
|
print("-" * 50)
|
|
try:
|
|
opd_auto = ZernikeOPDExtractor(op2_path, concave=True)
|
|
auto_focal = opd_auto.focal_length
|
|
print(f" Auto-estimated focal length: {auto_focal:.1f} mm")
|
|
|
|
opd_auto_results = opd_auto.extract_all_subcases()
|
|
for sc, data in opd_auto_results.items():
|
|
rms = data['rms_wfe_nm']
|
|
lat = data.get('max_lateral_displacement_um', 0)
|
|
print(f" Subcase {sc}: RMS WFE = {rms:.2f} nm, Max lateral = {lat:.2f} µm")
|
|
except Exception as e:
|
|
print(f" Error: {e}")
|
|
opd_auto_results = None
|
|
print()
|
|
|
|
# 3. OPD Zernike with correct prescription focal length
|
|
print("3. OPD ZERNIKE (prescription focal length = 1445 mm)")
|
|
print("-" * 50)
|
|
try:
|
|
opd_correct = ZernikeOPDExtractor(op2_path, focal_length=1445.0, concave=True)
|
|
print(f" Using focal length: {opd_correct.focal_length:.1f} mm")
|
|
|
|
opd_correct_results = opd_correct.extract_all_subcases()
|
|
for sc, data in opd_correct_results.items():
|
|
rms = data['rms_wfe_nm']
|
|
lat = data.get('max_lateral_displacement_um', 0)
|
|
print(f" Subcase {sc}: RMS WFE = {rms:.2f} nm, Max lateral = {lat:.2f} µm")
|
|
except Exception as e:
|
|
print(f" Error: {e}")
|
|
opd_correct_results = None
|
|
print()
|
|
|
|
# 4. Comparison summary
|
|
if std_results and opd_correct_results:
|
|
print("=" * 70)
|
|
print("COMPARISON SUMMARY")
|
|
print("=" * 70)
|
|
print()
|
|
print(f"{'Subcase':<10} {'Standard':<15} {'OPD (auto)':<15} {'OPD (1445mm)':<15} {'Diff %':<10}")
|
|
print("-" * 65)
|
|
|
|
for sc in std_results.keys():
|
|
std_rms = std_results[sc]['rms_wfe_nm']
|
|
auto_rms = opd_auto_results[sc]['rms_wfe_nm'] if opd_auto_results else 0
|
|
corr_rms = opd_correct_results[sc]['rms_wfe_nm']
|
|
diff_pct = ((corr_rms - std_rms) / std_rms * 100) if std_rms > 0 else 0
|
|
|
|
print(f"{sc:<10} {std_rms:<15.2f} {auto_rms:<15.2f} {corr_rms:<15.2f} {diff_pct:>+8.1f}%")
|
|
|
|
print()
|
|
print("LATERAL DISPLACEMENT ANALYSIS")
|
|
print("-" * 50)
|
|
|
|
for sc, data in opd_correct_results.items():
|
|
lat = data.get('max_lateral_displacement_um', 0)
|
|
severity = "CRITICAL - OPD method required" if lat > 10 else "Low - standard OK" if lat < 1 else "Moderate"
|
|
print(f" Subcase {sc}: Max lateral = {lat:.2f} µm ({severity})")
|
|
|
|
print()
|
|
|
|
# Tracking WFE comparison (40-20 and 60-20)
|
|
if 2 in opd_correct_results and 3 in opd_correct_results and 4 in opd_correct_results:
|
|
print("TRACKING WFE (differential between elevations)")
|
|
print("-" * 50)
|
|
|
|
# Get coefficients for differential analysis
|
|
z20 = np.array(opd_correct_results[2]['coefficients'])
|
|
z40 = np.array(opd_correct_results[3]['coefficients'])
|
|
z60 = np.array(opd_correct_results[4]['coefficients'])
|
|
|
|
# Differential (remove J1-J4: piston, tip, tilt, defocus)
|
|
diff_40_20 = z40 - z20
|
|
diff_60_20 = z60 - z20
|
|
|
|
# RMS of filtered differential (J5+)
|
|
rms_40_20 = np.sqrt(np.sum(diff_40_20[4:]**2)) # Skip J1-J4
|
|
rms_60_20 = np.sqrt(np.sum(diff_60_20[4:]**2))
|
|
|
|
print(f" 40°-20° tracking WFE: {rms_40_20:.2f} nm RMS (filtered)")
|
|
print(f" 60°-20° tracking WFE: {rms_60_20:.2f} nm RMS (filtered)")
|
|
|
|
print()
|
|
print(" Standard method comparison:")
|
|
z20_std = np.array(std_results[2]['coefficients'])
|
|
z40_std = np.array(std_results[3]['coefficients'])
|
|
z60_std = np.array(std_results[4]['coefficients'])
|
|
|
|
diff_40_20_std = z40_std - z20_std
|
|
diff_60_20_std = z60_std - z20_std
|
|
|
|
rms_40_20_std = np.sqrt(np.sum(diff_40_20_std[4:]**2))
|
|
rms_60_20_std = np.sqrt(np.sum(diff_60_20_std[4:]**2))
|
|
|
|
print(f" 40°-20° tracking WFE (std): {rms_40_20_std:.2f} nm RMS")
|
|
print(f" 60°-20° tracking WFE (std): {rms_60_20_std:.2f} nm RMS")
|
|
|
|
print()
|
|
print(f" Difference (OPD vs Standard):")
|
|
print(f" 40°-20°: {rms_40_20 - rms_40_20_std:+.2f} nm ({(rms_40_20/rms_40_20_std - 1)*100:+.1f}%)")
|
|
print(f" 60°-20°: {rms_60_20 - rms_60_20_std:+.2f} nm ({(rms_60_20/rms_60_20_std - 1)*100:+.1f}%)")
|
|
|
|
print()
|
|
print("=" * 70)
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description='Test ZernikeOPD with M1 prescription')
|
|
parser.add_argument('path', nargs='?', default='.',
|
|
help='Path to OP2 file or study directory')
|
|
|
|
args = parser.parse_args()
|
|
path = Path(args.path).resolve()
|
|
|
|
# Find OP2 file
|
|
if path.is_file() and path.suffix.lower() == '.op2':
|
|
op2_path = path
|
|
elif path.is_dir():
|
|
# Look for best design or recent iteration
|
|
patterns = [
|
|
'3_results/best_design_archive/**/*.op2',
|
|
'2_iterations/iter1/*.op2',
|
|
'**/*.op2'
|
|
]
|
|
for pattern in patterns:
|
|
files = list(path.glob(pattern))
|
|
if files:
|
|
op2_path = max(files, key=lambda p: p.stat().st_mtime)
|
|
break
|
|
else:
|
|
print(f"No OP2 file found in {path}")
|
|
sys.exit(1)
|
|
else:
|
|
print(f"Invalid path: {path}")
|
|
sys.exit(1)
|
|
|
|
run_comparison(op2_path)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|