## Cleanup (v0.5.0) - Delete 102+ orphaned MCP session temp files - Remove build artifacts (htmlcov, dist, __pycache__) - Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.) - Move debug/analysis scripts from tests/ to tools/analysis/ - Archive redundant NX journals to archive/nx_journals/ - Archive monolithic PROTOCOL.md to docs/archive/ - Update .gitignore with missing patterns - Clean old study files (optimization_log_old.txt, run_optimization_old.py) ## Canvas UX (Phases 7-9) - Phase 7: Resizable panels with localStorage persistence - Left sidebar: 200-400px, Right panel: 280-600px - New useResizablePanel hook and ResizeHandle component - Phase 8: Enable all palette items - All 8 node types now draggable - Singleton logic for model/solver/algorithm/surrogate - Phase 9: Solver configuration - Add SolverEngine type (nxnastran, mscnastran, python, etc.) - Add NastranSolutionType (SOL101-SOL200) - Engine/solution dropdowns in config panel - Python script path support ## Documentation - Update CHANGELOG.md with recent versions - Update docs/00_INDEX.md - Create examples/README.md - Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
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.")
|