feat: Major update - Physics docs, Zernike OPD, insights, NX journals, tools

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>
This commit is contained in:
2025-12-23 19:47:37 -05:00
parent e448142599
commit f13563d7ab
43 changed files with 8098 additions and 8 deletions

74
tests/audit_v10_fix.py Normal file
View File

@@ -0,0 +1,74 @@
"""Verify the V10 fix - compare Standard extract_relative vs OPD extract_relative."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from optimization_engine.extractors import ZernikeExtractor, ZernikeOPDExtractor
op2 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V10/2_iterations/iter1/assy_m1_assyfem1_sim1-solution_1.op2')
print("="*70)
print("VERIFICATION: ZernikeOPDExtractor.extract_relative() vs Standard")
print("="*70)
print()
# Standard extractor
extractor_std = ZernikeExtractor(op2, n_modes=50, filter_orders=4)
# OPD extractor (with XY lateral correction)
extractor_opd = ZernikeOPDExtractor(op2, n_modes=50, filter_orders=4)
print("Standard ZernikeExtractor.extract_relative():")
rel_40_std = extractor_std.extract_relative('3', '2')
rel_60_std = extractor_std.extract_relative('4', '2')
rel_90_std = extractor_std.extract_relative('1', '2')
print(f" 40-20: {rel_40_std['relative_filtered_rms_nm']:.2f} nm")
print(f" 60-20: {rel_60_std['relative_filtered_rms_nm']:.2f} nm")
print(f" 90-20 (j1to3): {rel_90_std['relative_rms_filter_j1to3']:.2f} nm")
print()
print("NEW ZernikeOPDExtractor.extract_relative() (with XY lateral correction):")
rel_40_opd = extractor_opd.extract_relative('3', '2')
rel_60_opd = extractor_opd.extract_relative('4', '2')
rel_90_opd = extractor_opd.extract_relative('1', '2')
print(f" 40-20: {rel_40_opd['relative_filtered_rms_nm']:.2f} nm")
print(f" 60-20: {rel_60_opd['relative_filtered_rms_nm']:.2f} nm")
print(f" 90-20 (j1to3): {rel_90_opd['relative_rms_filter_j1to3']:.2f} nm")
print()
print("Lateral displacement diagnostics (OPD method):")
print(f" Max lateral: {rel_40_opd['max_lateral_displacement_um']:.3f} um")
print(f" RMS lateral: {rel_40_opd['rms_lateral_displacement_um']:.3f} um")
print()
print("="*70)
print("COMPARISON")
print("="*70)
print()
print(f"{'Metric':<20} | {'Standard':<12} | {'OPD':<12} | {'Diff %':<10}")
print("-"*60)
def pct_diff(a, b):
return 100.0 * (b - a) / a if a > 0 else 0
print(f"{'40-20 (nm)':<20} | {rel_40_std['relative_filtered_rms_nm']:>12.2f} | {rel_40_opd['relative_filtered_rms_nm']:>12.2f} | {pct_diff(rel_40_std['relative_filtered_rms_nm'], rel_40_opd['relative_filtered_rms_nm']):>+10.1f}%")
print(f"{'60-20 (nm)':<20} | {rel_60_std['relative_filtered_rms_nm']:>12.2f} | {rel_60_opd['relative_filtered_rms_nm']:>12.2f} | {pct_diff(rel_60_std['relative_filtered_rms_nm'], rel_60_opd['relative_filtered_rms_nm']):>+10.1f}%")
print(f"{'90-20 j1to3 (nm)':<20} | {rel_90_std['relative_rms_filter_j1to3']:>12.2f} | {rel_90_opd['relative_rms_filter_j1to3']:>12.2f} | {pct_diff(rel_90_std['relative_rms_filter_j1to3'], rel_90_opd['relative_rms_filter_j1to3']):>+10.1f}%")
print()
print("="*70)
print("WHAT V9 REPORTED (for comparison)")
print("="*70)
print(" 40-20: 6.10 nm (from DB)")
print(" 60-20: 12.76 nm (from DB)")
print()
print("V10 SHOULD NOW REPORT (using OPD extract_relative):")
print(f" 40-20: {rel_40_opd['relative_filtered_rms_nm']:.2f} nm")
print(f" 60-20: {rel_60_opd['relative_filtered_rms_nm']:.2f} nm")
print(f" 90-20: {rel_90_opd['relative_rms_filter_j1to3']:.2f} nm")
print()
print("V10 OLD WRONG VALUES WERE:")
print(" 40-20: 1.99 nm (WRONG - was computing abs(RMS_target - RMS_ref))")
print(" 60-20: 6.82 nm (WRONG)")
print()
print("FIX VERIFIED: OPD extract_relative() correctly computes RMS of (WFE_target - WFE_ref)")

View File

@@ -0,0 +1,72 @@
"""Compare V9 vs V10 calculation methods."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from optimization_engine.extractors import ZernikeExtractor
op2 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V10/2_iterations/iter1/assy_m1_assyfem1_sim1-solution_1.op2')
extractor = ZernikeExtractor(op2, n_modes=50, filter_orders=4)
print("="*70)
print("CRITICAL: V9 vs V10 Calculation Method Comparison")
print("="*70)
print()
# This is what V9 does - computes relative WFE THEN fits Zernike
rel_40 = extractor.extract_relative('3', '2')
rel_60 = extractor.extract_relative('4', '2')
rel_90 = extractor.extract_relative('1', '2')
print('V9 method (ZernikeExtractor.extract_relative):')
print(' Computes WFE_diff = WFE_target - WFE_ref node-by-node')
print(' Then fits Zernike to WFE_diff')
print()
print(f' 40-20: {rel_40["relative_filtered_rms_nm"]:.2f} nm')
print(f' 60-20: {rel_60["relative_filtered_rms_nm"]:.2f} nm')
print(f' 90-20 (j1to3): {rel_90["relative_rms_filter_j1to3"]:.2f} nm')
# Individual absolute values
r20 = extractor.extract_subcase('2')
r40 = extractor.extract_subcase('3')
r60 = extractor.extract_subcase('4')
r90 = extractor.extract_subcase('1')
print()
print('='*70)
print('Individual absolute RMS values:')
print('='*70)
print(f' 20 deg: {r20["filtered_rms_nm"]:.2f} nm')
print(f' 40 deg: {r40["filtered_rms_nm"]:.2f} nm')
print(f' 60 deg: {r60["filtered_rms_nm"]:.2f} nm')
print(f' 90 deg: {r90["filtered_rms_nm"]:.2f} nm')
print()
print('='*70)
print('V10 method (WRONG - difference of RMS values):')
print(' Computes RMS_target - RMS_ref')
print(' This is NOT the same as RMS of the difference!')
print('='*70)
print()
print(f' 40-20: {r40["filtered_rms_nm"] - r20["filtered_rms_nm"]:.2f} nm')
print(f' 60-20: {r60["filtered_rms_nm"] - r20["filtered_rms_nm"]:.2f} nm')
print(f' After abs(): {abs(r40["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm')
print(f' After abs(): {abs(r60["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm')
print()
print('='*70)
print('CONCLUSION')
print('='*70)
print()
print('V10 BUG: Computes abs(RMS_target - RMS_ref) instead of RMS(WFE_target - WFE_ref)')
print()
print('The CORRECT relative WFE (from V9 method):')
print(f' 40-20: {rel_40["relative_filtered_rms_nm"]:.2f} nm')
print(f' 60-20: {rel_60["relative_filtered_rms_nm"]:.2f} nm')
print(f' 90-20: {rel_90["relative_rms_filter_j1to3"]:.2f} nm')
print()
print('The WRONG values V10 reports:')
print(f' 40-20: {abs(r40["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm')
print(f' 60-20: {abs(r60["filtered_rms_nm"] - r20["filtered_rms_nm"]):.2f} nm')
print()
print('V10 values are ~3-4x LOWER than correct values!')

143
tests/audit_v10_wfe.py Normal file
View File

@@ -0,0 +1,143 @@
"""Audit V10 WFE values - independent verification."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from optimization_engine.extractors import ZernikeOPDExtractor, ZernikeExtractor
print('='*70)
print('AUDIT: V10 WFE Values - Independent Verification')
print('='*70)
# V10 iter1 (baseline trial)
op2_v10 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V10/2_iterations/iter1/assy_m1_assyfem1_sim1-solution_1.op2')
if not op2_v10.exists():
print('V10 OP2 file not found!')
sys.exit(1)
print(f'OP2 file: {op2_v10}')
print(f'Size: {op2_v10.stat().st_size / 1024 / 1024:.1f} MB')
# Test with ZernikeOPDExtractor (what V10 uses)
print()
print('='*70)
print('Method 1: ZernikeOPDExtractor (what V10 uses)')
print('='*70)
extractor_opd = ZernikeOPDExtractor(op2_v10, n_modes=50, filter_orders=4)
result_20_opd = extractor_opd.extract_subcase('2') # Reference
result_40_opd = extractor_opd.extract_subcase('3') # 40 deg
result_60_opd = extractor_opd.extract_subcase('4') # 60 deg
result_90_opd = extractor_opd.extract_subcase('1') # 90 deg MFG
print()
print('ABSOLUTE values (ZernikeOPD):')
print(f' 20 deg: filtered_rms = {result_20_opd["filtered_rms_nm"]:.2f} nm')
print(f' 40 deg: filtered_rms = {result_40_opd["filtered_rms_nm"]:.2f} nm')
print(f' 60 deg: filtered_rms = {result_60_opd["filtered_rms_nm"]:.2f} nm')
print(f' 90 deg: filtered_rms = {result_90_opd["filtered_rms_nm"]:.2f} nm')
print()
print('RELATIVE values (target - ref) as V10 computes:')
rel_40_opd = result_40_opd['filtered_rms_nm'] - result_20_opd['filtered_rms_nm']
rel_60_opd = result_60_opd['filtered_rms_nm'] - result_20_opd['filtered_rms_nm']
rel_mfg_opd = result_90_opd['rms_filter_j1to3_nm'] - result_20_opd['rms_filter_j1to3_nm']
print(f' 40-20: {rel_40_opd:.2f} nm (abs: {abs(rel_40_opd):.2f})')
print(f' 60-20: {rel_60_opd:.2f} nm (abs: {abs(rel_60_opd):.2f})')
print(f' 90-20 (j1to3): {rel_mfg_opd:.2f} nm (abs: {abs(rel_mfg_opd):.2f})')
print()
print('V10 uses abs() -> stores:')
print(f' rel_filtered_rms_40_vs_20: {abs(rel_40_opd):.2f}')
print(f' rel_filtered_rms_60_vs_20: {abs(rel_60_opd):.2f}')
print(f' mfg_90_optician_workload: {abs(rel_mfg_opd):.2f}')
# Test with Standard ZernikeExtractor (what V9 uses)
print()
print('='*70)
print('Method 2: Standard ZernikeExtractor (what V9 likely uses)')
print('='*70)
# Find the BDF file
bdf_files = list(op2_v10.parent.glob('*.dat'))
bdf_path = bdf_files[0] if bdf_files else None
print(f'BDF file: {bdf_path}')
extractor_std = ZernikeExtractor(op2_v10, bdf_path=bdf_path, n_modes=50, filter_orders=4)
result_20_std = extractor_std.extract_subcase('2')
result_40_std = extractor_std.extract_subcase('3')
result_60_std = extractor_std.extract_subcase('4')
result_90_std = extractor_std.extract_subcase('1')
print()
print('ABSOLUTE values (Standard Z-only):')
print(f' 20 deg: filtered_rms = {result_20_std["filtered_rms_nm"]:.2f} nm')
print(f' 40 deg: filtered_rms = {result_40_std["filtered_rms_nm"]:.2f} nm')
print(f' 60 deg: filtered_rms = {result_60_std["filtered_rms_nm"]:.2f} nm')
print(f' 90 deg: filtered_rms = {result_90_std["filtered_rms_nm"]:.2f} nm')
print()
print('RELATIVE values (Standard):')
rel_40_std = result_40_std['filtered_rms_nm'] - result_20_std['filtered_rms_nm']
rel_60_std = result_60_std['filtered_rms_nm'] - result_20_std['filtered_rms_nm']
print(f' 40-20: {rel_40_std:.2f} nm (abs: {abs(rel_40_std):.2f})')
print(f' 60-20: {rel_60_std:.2f} nm (abs: {abs(rel_60_std):.2f})')
# Compare
print()
print('='*70)
print('COMPARISON: OPD vs Standard')
print('='*70)
print()
print(f'40-20: OPD={abs(rel_40_opd):.2f} nm vs Standard={abs(rel_40_std):.2f} nm')
print(f'60-20: OPD={abs(rel_60_opd):.2f} nm vs Standard={abs(rel_60_std):.2f} nm')
print()
print('Lateral displacement (OPD method):')
print(f' Max: {result_40_opd.get("max_lateral_displacement_um", 0):.3f} um')
print(f' RMS: {result_40_opd.get("rms_lateral_displacement_um", 0):.3f} um')
# Now check what V9 reports
print()
print('='*70)
print('V9 COMPARISON (iter12 from best archive)')
print('='*70)
op2_v9 = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V9/2_iterations/iter12/assy_m1_assyfem1_sim1-solution_1.op2')
if op2_v9.exists():
extractor_v9_opd = ZernikeOPDExtractor(op2_v9, n_modes=50, filter_orders=4)
extractor_v9_std = ZernikeExtractor(op2_v9, n_modes=50, filter_orders=4)
r20_v9_opd = extractor_v9_opd.extract_subcase('2')
r40_v9_opd = extractor_v9_opd.extract_subcase('3')
r60_v9_opd = extractor_v9_opd.extract_subcase('4')
r20_v9_std = extractor_v9_std.extract_subcase('2')
r40_v9_std = extractor_v9_std.extract_subcase('3')
r60_v9_std = extractor_v9_std.extract_subcase('4')
rel_40_v9_opd = abs(r40_v9_opd['filtered_rms_nm'] - r20_v9_opd['filtered_rms_nm'])
rel_60_v9_opd = abs(r60_v9_opd['filtered_rms_nm'] - r20_v9_opd['filtered_rms_nm'])
rel_40_v9_std = abs(r40_v9_std['filtered_rms_nm'] - r20_v9_std['filtered_rms_nm'])
rel_60_v9_std = abs(r60_v9_std['filtered_rms_nm'] - r20_v9_std['filtered_rms_nm'])
print()
print('V9 iter12 relative values:')
print(f' 40-20: OPD={rel_40_v9_opd:.2f} nm vs Standard={rel_40_v9_std:.2f} nm')
print(f' 60-20: OPD={rel_60_v9_opd:.2f} nm vs Standard={rel_60_v9_std:.2f} nm')
else:
print('V9 OP2 not found')
print()
print('='*70)
print('SUMMARY')
print('='*70)
print()
print('V10 reports: 40-20=1.99nm, 60-20=6.82nm (using OPD method)')
print('V9 reports: 40-20=6.10nm, 60-20=12.76nm (likely Standard method)')
print()
print('If both studies have SIMILAR geometry, the OPD method should NOT')
print('give such dramatically different values. This needs investigation.')

20
tests/check_api_routes.py Normal file
View File

@@ -0,0 +1,20 @@
"""Check API routes from running backend."""
import requests
import json
# Get OpenAPI spec
resp = requests.get("http://localhost:8000/openapi.json", timeout=10)
spec = resp.json()
# Find insight routes
print("Insight-related routes:")
print("=" * 60)
for path in sorted(spec.get("paths", {}).keys()):
if "insight" in path.lower():
print(f" {path}")
print()
print("All routes:")
print("-" * 60)
for path in sorted(spec.get("paths", {}).keys()):
print(f" {path}")

View File

@@ -0,0 +1,83 @@
"""Debug script to compare figure.dat vs BDF node coordinates."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import numpy as np
import logging
logging.disable(logging.WARNING)
study_dir = Path(r"c:\Users\antoi\Atomizer\studies\M1_Mirror\m1_mirror_cost_reduction_V9")
# Load figure.dat
from optimization_engine.extractors.extract_zernike_figure import load_figure_geometry
fig_geo = load_figure_geometry(study_dir / "1_setup/model/figure.dat")
fig_nids = set(fig_geo.keys())
# Find OP2 and BDF
op2_file = list(study_dir.glob("3_results/best_design_archive/**/*.op2"))[0]
bdf_file = op2_file.with_suffix(".dat")
# Load BDF
from pyNastran.bdf.bdf import BDF
bdf = BDF(log=None, debug=False)
bdf.read_bdf(str(bdf_file))
bdf_nids = set(bdf.nodes.keys())
# Load OP2
from pyNastran.op2.op2 import OP2
op2 = OP2(log=None, debug=False)
op2.read_op2(str(op2_file))
disps = op2.displacements
first_key = list(disps.keys())[0]
op2_nids = set(int(n) for n in disps[first_key].node_gridtype[:,0])
print(f"Figure.dat nodes: {len(fig_nids)}")
print(f"BDF nodes: {len(bdf_nids)}")
print(f"OP2 nodes: {len(op2_nids)}")
print()
print(f"Figure ^ BDF: {len(fig_nids & bdf_nids)}")
print(f"Figure ^ OP2: {len(fig_nids & op2_nids)}")
print(f"BDF ^ OP2: {len(bdf_nids & op2_nids)}")
# Sample coords - use a node in all three
common_nids = list(fig_nids & bdf_nids & op2_nids)[:5]
print()
print("Sample common node coords comparison:")
z_diffs = []
for nid in common_nids:
fig_pos = fig_geo[nid]
bdf_pos = bdf.nodes[nid].get_position()
diff = np.array(fig_pos) - bdf_pos
z_diffs.append(diff[2])
print(f" Node {nid}:")
print(f" Figure: ({fig_pos[0]:.6f}, {fig_pos[1]:.6f}, {fig_pos[2]:.9f})")
print(f" BDF: ({bdf_pos[0]:.6f}, {bdf_pos[1]:.6f}, {bdf_pos[2]:.9f})")
print(f" Z diff: {diff[2]*1e6:.3f} nm")
# Statistics on all matching nodes
all_common = fig_nids & bdf_nids
all_z_diffs = []
all_xy_diffs = []
for nid in all_common:
fig_pos = np.array(fig_geo[nid])
bdf_pos = bdf.nodes[nid].get_position()
diff = fig_pos - bdf_pos
all_z_diffs.append(diff[2])
all_xy_diffs.append(np.sqrt(diff[0]**2 + diff[1]**2))
all_z_diffs = np.array(all_z_diffs)
all_xy_diffs = np.array(all_xy_diffs)
print()
print(f"=== ALL {len(all_common)} COMMON NODES ===")
print(f"Z difference (figure - BDF):")
print(f" Min: {all_z_diffs.min()*1e6:.3f} nm")
print(f" Max: {all_z_diffs.max()*1e6:.3f} nm")
print(f" Mean: {all_z_diffs.mean()*1e6:.3f} nm")
print(f" RMS: {np.sqrt(np.mean(all_z_diffs**2))*1e6:.3f} nm")
print()
print(f"XY difference (figure - BDF):")
print(f" Max: {all_xy_diffs.max()*1e3:.6f} um")
print(f" RMS: {np.sqrt(np.mean(all_xy_diffs**2))*1e3:.6f} um")

50
tests/debug_insights.py Normal file
View File

@@ -0,0 +1,50 @@
"""Debug insights availability for a study."""
import sys
sys.path.insert(0, ".")
from pathlib import Path
# Test study path resolution
study_id = 'm1_mirror_cost_reduction_V9'
STUDIES_DIR = Path('studies')
# Check nested path
for topic_dir in STUDIES_DIR.iterdir():
if topic_dir.is_dir():
study_dir = topic_dir / study_id
if study_dir.exists():
print(f"Found study at: {study_dir}")
print(f"Has 1_setup: {(study_dir / '1_setup').exists()}")
print(f"Has 2_results: {(study_dir / '2_results').exists()}")
# Check what insights are available
from optimization_engine.insights import list_available_insights, get_configured_insights, recommend_insights_for_study
print("\n--- Available insights (can_generate=True) ---")
available = list_available_insights(study_dir)
print(f"Count: {len(available)}")
for a in available:
print(f" - {a}")
print("\n--- Configured insights ---")
configured = get_configured_insights(study_dir)
print(f"Count: {len(configured)}")
for c in configured:
print(f" - {c.type}: {c.name}")
print("\n--- Recommendations ---")
recs = recommend_insights_for_study(study_dir)
print(f"Count: {len(recs)}")
for r in recs:
print(f" - {r['type']}: {r['name']}")
# Test individual insight can_generate
print("\n--- Testing each insight's can_generate ---")
from optimization_engine.insights import get_insight, list_insights
for info in list_insights():
insight = get_insight(info['type'], study_dir)
if insight:
can = insight.can_generate()
print(f" {info['type']:20} can_generate={can}")
break

View File

@@ -0,0 +1,21 @@
"""Test if insights can be imported from backend context."""
import sys
from pathlib import Path
# Replicate the path setup from main.py
backend_path = Path(__file__).parent.parent / "atomizer-dashboard" / "backend" / "api"
sys.path.insert(0, str(backend_path.parent.parent.parent.parent))
sys.path.insert(0, str(backend_path.parent))
print(f"sys.path[0]: {sys.path[0]}")
print(f"sys.path[1]: {sys.path[1]}")
try:
from api.routes import insights
print(f"insights module imported: {insights}")
print(f"insights.router: {insights.router}")
print(f"routes: {[r.path for r in insights.router.routes]}")
except Exception as e:
print(f"ERROR importing insights: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Test script for Zernike extractor import."""
import sys
from pathlib import Path
# Add Atomizer root to path
atomizer_root = Path(__file__).parent.parent
sys.path.insert(0, str(atomizer_root))
print("Testing ZernikeExtractor import...")
try:
from optimization_engine.extractors import ZernikeExtractor
print(" Import: OK")
import inspect
sig = inspect.signature(ZernikeExtractor.extract_relative)
print(f" extract_relative signature: {sig}")
# Check parameters
params = list(sig.parameters.keys())
print(f" Parameters: {params}")
if 'include_coefficients' in params:
print(" include_coefficients parameter: FOUND")
else:
print(" include_coefficients parameter: MISSING!")
sys.exit(1)
except Exception as e:
print(f" ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
print("\nAll tests passed!")

View File

@@ -0,0 +1,31 @@
"""Test script for Zernike WFE insight with OPD method."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from optimization_engine.insights.zernike_wfe import ZernikeWFEInsight
from optimization_engine.insights.base import InsightConfig
study = Path('studies/M1_Mirror/m1_mirror_cost_reduction_V9')
insight = ZernikeWFEInsight(study)
if insight.can_generate():
print('Insight can be generated!')
print(f'OP2: {insight.op2_path}')
print(f'Geo: {insight.geo_path}')
config = InsightConfig()
result = insight.generate(config)
if result.success:
n_files = len(result.summary.get('html_files', []))
print(f'Success! Generated {n_files} files')
for f in result.summary.get('html_files', []):
print(f' - {Path(f).name}')
print()
print('Summary:')
for k, v in result.summary.items():
if k != 'html_files':
print(f' {k}: {v}')
else:
print(f'Failed: {result.error}')
else:
print('Cannot generate insight')

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python
"""
Quick test script to compare Standard vs OPD Zernike methods.
Usage:
conda activate atomizer
python test_zernike_opd_comparison.py
This will analyze a recent OP2 file and show you:
1. How much lateral displacement exists
2. How different the WFE metrics are between methods
3. Whether you need to switch to OPD method for your optimizations
"""
from pathlib import Path
import sys
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent))
def main():
import numpy as np
from optimization_engine.extractors.extract_zernike_opd import (
ZernikeOPDExtractor,
)
# Find a recent OP2 file from your studies
studies_path = Path("studies/M1_Mirror")
op2_files = list(studies_path.glob("**/2_iterations/**/*.op2"))
if not op2_files:
op2_files = list(studies_path.glob("**/*.op2"))
if not op2_files:
print("No OP2 files found in studies/M1_Mirror")
return
# Use the most recent one
op2_file = max(op2_files, key=lambda p: p.stat().st_mtime)
print(f"Analyzing: {op2_file}")
print("=" * 80)
# Run comparison
try:
extractor = ZernikeOPDExtractor(op2_file)
print(f"\nAvailable subcases: {list(extractor.displacements.keys())}")
# Show geometry info
geo = extractor.node_geometry
all_pos = np.array(list(geo.values()))
print(f"\n--- Geometry Info ---")
print(f" Nodes: {len(geo)}")
print(f" X range: {all_pos[:,0].min():.1f} to {all_pos[:,0].max():.1f} mm")
print(f" Y range: {all_pos[:,1].min():.1f} to {all_pos[:,1].max():.1f} mm")
print(f" Z range: {all_pos[:,2].min():.1f} to {all_pos[:,2].max():.1f} mm")
for label in extractor.displacements.keys():
print(f"\n{'=' * 80}")
print(f"SUBCASE {label}")
print('=' * 80)
comparison = extractor.extract_comparison(label)
print(f"\n--- Standard Method (Z-only) ---")
print(f" Global RMS: {comparison['standard_method']['global_rms_nm']:.2f} nm")
print(f" Filtered RMS: {comparison['standard_method']['filtered_rms_nm']:.2f} nm")
print(f"\n--- Rigorous OPD Method ---")
print(f" Global RMS: {comparison['opd_method']['global_rms_nm']:.2f} nm")
print(f" Filtered RMS: {comparison['opd_method']['filtered_rms_nm']:.2f} nm")
print(f"\n--- Difference (OPD - Standard) ---")
delta = comparison['delta']['filtered_rms_nm']
pct = comparison['delta']['percent_difference_filtered']
sign = "+" if delta > 0 else ""
print(f" Filtered RMS: {sign}{delta:.2f} nm ({sign}{pct:.1f}%)")
print(f"\n--- Lateral Displacement ---")
print(f" Max: {comparison['lateral_displacement']['max_um']:.3f} µm")
print(f" RMS: {comparison['lateral_displacement']['rms_um']:.3f} µm")
print(f" P99: {comparison['lateral_displacement']['p99_um']:.3f} µm")
print(f"\n>>> {comparison['recommendation']}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
return
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,199 @@
#!/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()