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>
234 lines
6.8 KiB
Python
234 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Atomizer Zernike WFE Analyzer
|
|
=============================
|
|
|
|
Analyze Zernike wavefront error from NX Nastran OP2 results.
|
|
|
|
IMPORTANT: This script requires numpy/scipy. Run from command line with
|
|
the atomizer conda environment, NOT from within NX.
|
|
|
|
Usage:
|
|
conda activate atomizer
|
|
python analyze_wfe_zernike.py "path/to/solution.op2"
|
|
|
|
# Or without argument - searches current directory for OP2 files:
|
|
python analyze_wfe_zernike.py
|
|
|
|
Output:
|
|
- Zernike coefficients for each subcase
|
|
- Relative WFE metrics (filtered RMS)
|
|
- Manufacturing workload (J1-J3 filtered)
|
|
- Weighted sum calculation
|
|
|
|
Author: Atomizer
|
|
Created: 2025-12-18
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
def log(msg):
|
|
"""Print to console."""
|
|
print(msg)
|
|
|
|
|
|
def find_op2_file(working_dir=None):
|
|
"""Find the most recent OP2 file in the working directory."""
|
|
if working_dir is None:
|
|
working_dir = Path.cwd()
|
|
else:
|
|
working_dir = Path(working_dir)
|
|
|
|
# Look for OP2 files
|
|
op2_files = list(working_dir.glob("*solution*.op2")) + list(working_dir.glob("*.op2"))
|
|
|
|
if not op2_files:
|
|
# Check subdirectories
|
|
op2_files = list(working_dir.glob("**/*solution*.op2"))
|
|
|
|
if not op2_files:
|
|
return None
|
|
|
|
# Return most recently modified
|
|
return max(op2_files, key=lambda p: p.stat().st_mtime)
|
|
|
|
def analyze_zernike(op2_path):
|
|
"""Run Zernike analysis on OP2 file."""
|
|
|
|
# Add Atomizer to path
|
|
atomizer_root = Path(__file__).parent.parent
|
|
if str(atomizer_root) not in sys.path:
|
|
sys.path.insert(0, str(atomizer_root))
|
|
|
|
try:
|
|
from optimization_engine.extractors import ZernikeExtractor
|
|
except ImportError as e:
|
|
log(f"ERROR: Could not import ZernikeExtractor: {e}")
|
|
log(f"Make sure Atomizer is properly installed.")
|
|
log(f"Atomizer root: {atomizer_root}")
|
|
return None
|
|
|
|
log("=" * 70)
|
|
log("ZERNIKE WAVEFRONT ERROR ANALYSIS")
|
|
log("=" * 70)
|
|
log(f"OP2 File: {op2_path.name}")
|
|
log(f"Directory: {op2_path.parent}")
|
|
log("")
|
|
|
|
# Create extractor
|
|
try:
|
|
extractor = ZernikeExtractor(
|
|
op2_path,
|
|
bdf_path=None,
|
|
displacement_unit='mm',
|
|
n_modes=50,
|
|
filter_orders=4
|
|
)
|
|
except Exception as e:
|
|
log(f"ERROR creating extractor: {e}")
|
|
return None
|
|
|
|
# Get available subcases from the extractor's displacement data
|
|
subcases = list(extractor.displacements.keys())
|
|
log(f"Available subcases: {subcases}")
|
|
log("")
|
|
|
|
# Standard subcase mapping for M1 mirror
|
|
subcase_labels = {
|
|
'1': '90 deg (Manufacturing/Polishing)',
|
|
'2': '20 deg (Reference)',
|
|
'3': '40 deg (Operational)',
|
|
'4': '60 deg (Operational)'
|
|
}
|
|
|
|
# Extract absolute Zernike for each subcase
|
|
log("-" * 70)
|
|
log("ABSOLUTE ZERNIKE ANALYSIS (per subcase)")
|
|
log("-" * 70)
|
|
|
|
results = {}
|
|
for sc in subcases:
|
|
try:
|
|
result = extractor.extract_subcase(sc)
|
|
results[sc] = result
|
|
label = subcase_labels.get(sc, f'Subcase {sc}')
|
|
log(f"\n{label}:")
|
|
log(f" Global RMS: {result['global_rms_nm']:.2f} nm")
|
|
log(f" Filtered RMS: {result['filtered_rms_nm']:.2f} nm (J4+ only)")
|
|
except Exception as e:
|
|
log(f" ERROR extracting subcase {sc}: {e}")
|
|
|
|
# Relative analysis (using subcase 2 as reference)
|
|
ref_subcase = '2'
|
|
if ref_subcase in subcases:
|
|
log("")
|
|
log("-" * 70)
|
|
log(f"RELATIVE ANALYSIS (vs {subcase_labels.get(ref_subcase, ref_subcase)})")
|
|
log("-" * 70)
|
|
|
|
relative_results = {}
|
|
for sc in subcases:
|
|
if sc == ref_subcase:
|
|
continue
|
|
try:
|
|
rel = extractor.extract_relative(sc, ref_subcase)
|
|
relative_results[sc] = rel
|
|
label = subcase_labels.get(sc, f'Subcase {sc}')
|
|
log(f"\n{label} vs Reference:")
|
|
log(f" Relative Filtered RMS: {rel['relative_filtered_rms_nm']:.2f} nm")
|
|
if 'relative_rms_filter_j1to3' in rel:
|
|
log(f" J1-J3 Filtered RMS: {rel['relative_rms_filter_j1to3']:.2f} nm")
|
|
except Exception as e:
|
|
log(f" ERROR: {e}")
|
|
|
|
# Calculate weighted sum (M1 mirror optimization objectives)
|
|
log("")
|
|
log("-" * 70)
|
|
log("OPTIMIZATION OBJECTIVES")
|
|
log("-" * 70)
|
|
|
|
obj_40_20 = relative_results.get('3', {}).get('relative_filtered_rms_nm', 0)
|
|
obj_60_20 = relative_results.get('4', {}).get('relative_filtered_rms_nm', 0)
|
|
obj_mfg = relative_results.get('1', {}).get('relative_rms_filter_j1to3', 0)
|
|
|
|
log(f"\n 40-20 Filtered RMS: {obj_40_20:.2f} nm")
|
|
log(f" 60-20 Filtered RMS: {obj_60_20:.2f} nm")
|
|
log(f" MFG 90 (J1-J3): {obj_mfg:.2f} nm")
|
|
|
|
# Weighted sums for different weight configurations
|
|
log("")
|
|
log("Weighted Sum Calculations:")
|
|
|
|
# V4 weights: 5*40 + 5*60 + 2*mfg + mass
|
|
ws_v4 = 5*obj_40_20 + 5*obj_60_20 + 2*obj_mfg
|
|
log(f" V4 weights (5/5/2): {ws_v4:.2f} (+ mass)")
|
|
|
|
# V5 weights: 5*40 + 5*60 + 3*mfg + mass
|
|
ws_v5 = 5*obj_40_20 + 5*obj_60_20 + 3*obj_mfg
|
|
log(f" V5 weights (5/5/3): {ws_v5:.2f} (+ mass)")
|
|
|
|
return {
|
|
'absolute': results,
|
|
'relative': relative_results,
|
|
'objectives': {
|
|
'40_20': obj_40_20,
|
|
'60_20': obj_60_20,
|
|
'mfg_90': obj_mfg,
|
|
'ws_v4': ws_v4,
|
|
'ws_v5': ws_v5
|
|
}
|
|
}
|
|
|
|
return {'absolute': results}
|
|
|
|
def main(args):
|
|
"""Main entry point."""
|
|
log("")
|
|
log("=" * 70)
|
|
log(" ATOMIZER ZERNIKE WFE ANALYZER")
|
|
log("=" * 70)
|
|
log("")
|
|
|
|
# Determine OP2 file
|
|
op2_path = None
|
|
|
|
if args and len(args) > 0 and args[0]:
|
|
# OP2 path provided as argument
|
|
op2_path = Path(args[0])
|
|
if not op2_path.exists():
|
|
log(f"ERROR: OP2 file not found: {op2_path}")
|
|
return
|
|
else:
|
|
# Try to find OP2 in current directory
|
|
log("No OP2 file specified, searching...")
|
|
op2_path = find_op2_file()
|
|
|
|
if op2_path is None:
|
|
log("ERROR: No OP2 file found in current directory.")
|
|
log("Usage: Run after solving, or provide OP2 path as argument.")
|
|
return
|
|
|
|
log(f"Found: {op2_path}")
|
|
|
|
# Run analysis
|
|
results = analyze_zernike(op2_path)
|
|
|
|
if results:
|
|
log("")
|
|
log("=" * 70)
|
|
log("ANALYSIS COMPLETE")
|
|
log("=" * 70)
|
|
else:
|
|
log("")
|
|
log("Analysis failed. Check errors above.")
|
|
|
|
if __name__ == '__main__':
|
|
# Get arguments (works both in NX and command line)
|
|
if len(sys.argv) > 1:
|
|
main(sys.argv[1:])
|
|
else:
|
|
main([])
|