Files
Atomizer/docs/06_PROTOCOLS_DETAILED/ZERNIKE_MIRROR_OPTIMIZATION.md
Antoine 8cbdbcad78 feat: Add Protocol 13 adaptive optimization, Plotly charts, and dashboard improvements
## Protocol 13: Adaptive Multi-Objective Optimization
- Iterative FEA + Neural Network surrogate workflow
- Initial FEA sampling, NN training, NN-accelerated search
- FEA validation of top NN predictions, retraining loop
- adaptive_state.json tracks iteration history and best values
- M1 mirror study (V11) with 103 FEA, 3000 NN trials

## Dashboard Visualization Enhancements
- Added Plotly.js interactive charts (parallel coords, Pareto, convergence)
- Lazy loading with React.lazy() for performance
- Code splitting: plotly.js-basic-dist (~1MB vs 3.5MB)
- Chart library toggle (Recharts default, Plotly on-demand)
- ExpandableChart component for full-screen modal views
- ConsoleOutput component for real-time log viewing

## Documentation
- Protocol 13 detailed documentation
- Dashboard visualization guide
- Plotly components README
- Updated run-optimization skill with Mode 5 (adaptive)

## Bug Fixes
- Fixed TypeScript errors in dashboard components
- Fixed Card component to accept ReactNode title
- Removed unused imports across components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 07:41:54 -05:00

357 lines
10 KiB
Markdown

# Zernike Mirror Optimization Protocol
## Overview
This document captures the learnings from the M1 mirror Zernike optimization studies (V1-V9), including the Assembly FEM (AFEM) workflow, subcase handling, and wavefront error metrics.
## Assembly FEM (AFEM) Structure
### NX File Organization
A typical telescope mirror assembly in NX consists of:
```
ASSY_M1.prt # Master assembly part
ASSY_M1_assyfem1.afm # Assembly FEM container
ASSY_M1_assyfem1_sim1.sim # Simulation file (this is what we solve)
M1_Blank.prt # Mirror blank part
M1_Blank_fem1.fem # Mirror blank mesh
M1_Blank_fem1_i.prt # Idealized geometry for FEM
M1_Vertical_Support_Skeleton.prt # Support structure part
M1_Vertical_Support_Skeleton_fem1.fem
M1_Vertical_Support_Skeleton_fem1_i.prt
```
### Key Relationships
1. **Assembly Part (.prt)** - Contains the CAD geometry and expressions (design parameters)
2. **Assembly FEM (.afm)** - Links component FEMs together, defines connections
3. **Simulation (.sim)** - Contains solutions, loads, boundary conditions, subcases
4. **Component FEMs (.fem)** - Individual meshes that get assembled
### Expression Propagation
Expressions defined in the master `.prt` propagate through the assembly:
- Modify expression in `ASSY_M1.prt`
- AFEM updates mesh connections automatically
- Solve via `.sim` file
## Multi-Subcase Analysis
### Telescope Gravity Orientations
For telescope mirrors, we analyze multiple gravity orientations (subcases):
| Subcase | Elevation Angle | Purpose |
|---------|-----------------|---------|
| 1 | 90 deg (zenith) | Polishing orientation - manufacturing reference |
| 2 | 20 deg | Low elevation - reference for relative metrics |
| 3 | 40 deg | Mid-low elevation |
| 4 | 60 deg | Mid-high elevation |
### Subcase Mapping
**Important**: NX subcase numbers don't always match angle labels!
```json
"subcase_labels": {
"1": "90deg", // Subcase 1 = 90 degrees
"2": "20deg", // Subcase 2 = 20 degrees (reference)
"3": "40deg", // Subcase 3 = 40 degrees
"4": "60deg" // Subcase 4 = 60 degrees
}
```
Always verify subcase-to-angle mapping by checking the NX simulation setup.
## Zernike Wavefront Error Analysis
### Optical Convention
For mirror surface deformation to wavefront error:
```
WFE = 2 * surface_displacement (reflection doubles the path difference)
```
Unit conversion:
```python
NM_PER_MM = 1e6 # 1 mm displacement = 1e6 nm WFE contribution
wfe_nm = 2.0 * displacement_mm * 1e6
```
### Zernike Polynomial Indexing
We use **Noll indexing** (standard in optics):
| J | Name | (n,m) | Correctable? |
|---|------|-------|--------------|
| 1 | Piston | (0,0) | Yes - alignment |
| 2 | Tilt X | (1,-1) | Yes - alignment |
| 3 | Tilt Y | (1,1) | Yes - alignment |
| 4 | Defocus | (2,0) | Yes - focus adjustment |
| 5 | Astigmatism 45 | (2,-2) | Partially |
| 6 | Astigmatism 0 | (2,2) | Partially |
| 7 | Coma X | (3,-1) | No |
| 8 | Coma Y | (3,1) | No |
| 9 | Trefoil X | (3,-3) | No |
| 10 | Trefoil Y | (3,3) | No |
| 11 | Spherical | (4,0) | No |
### RMS Metrics
| Metric | Filter | Use Case |
|--------|--------|----------|
| `global_rms_nm` | None | Total surface error |
| `filtered_rms_nm` | J1-J4 removed | Uncorrectable error (optimization target) |
| `rms_filter_j1to3` | J1-J3 removed | Optician workload (keeps defocus) |
### Relative Metrics
For gravity-induced deformation, we compute relative WFE:
```
WFE_relative = WFE_target_orientation - WFE_reference_orientation
```
This removes the static (manufacturing) shape and isolates gravity effects.
Example: `rel_filtered_rms_40_vs_20` = filtered RMS at 40 deg relative to 20 deg reference
## Optimization Objectives
### Typical M1 Mirror Objectives
```json
"objectives": [
{
"name": "rel_filtered_rms_40_vs_20",
"description": "Gravity-induced WFE at 40 deg vs 20 deg reference",
"direction": "minimize",
"weight": 5.0,
"target": 4.0,
"units": "nm"
},
{
"name": "rel_filtered_rms_60_vs_20",
"description": "Gravity-induced WFE at 60 deg vs 20 deg reference",
"direction": "minimize",
"weight": 5.0,
"target": 10.0,
"units": "nm"
},
{
"name": "mfg_90_optician_workload",
"description": "Polishing effort at zenith (J1-J3 filtered)",
"direction": "minimize",
"weight": 1.0,
"target": 20.0,
"units": "nm"
}
]
```
### Weighted Sum Formulation
```python
weighted_objective = sum(weight_i * (value_i / target_i)) / sum(weight_i)
```
Targets normalize different metrics to comparable scales.
## Design Variables
### Typical Mirror Support Parameters
| Parameter | Description | Typical Range |
|-----------|-------------|---------------|
| `whiffle_min` | Whiffle tree minimum dimension | 35-55 mm |
| `whiffle_outer_to_vertical` | Whiffle arm angle | 68-80 deg |
| `whiffle_triangle_closeness` | Triangle geometry | 50-65 mm |
| `inner_circular_rib_dia` | Rib diameter | 480-620 mm |
| `lateral_inner_angle` | Lateral support angle | 25-28.5 deg |
| `blank_backface_angle` | Mirror blank geometry | 3.5-5.0 deg |
### Expression File Format (params.exp)
```
[mm]whiffle_min=42.49
[Degrees]whiffle_outer_to_vertical=79.41
[mm]inner_circular_rib_dia=582.48
```
## Iteration Folder Structure (V9)
```
study_name/
├── 1_setup/
│ ├── model/ # Master NX files (NEVER modify)
│ └── optimization_config.json
├── 2_iterations/
│ ├── iter0/ # Trial 0 (0-based to match Optuna)
│ │ ├── [all NX files] # Fresh copy from master
│ │ ├── params.exp # Expression updates for this trial
│ │ └── results/ # Processed outputs
│ ├── iter1/
│ └── ...
└── 3_results/
└── study.db # Optuna database
```
### Why 0-Based Iteration Folders?
Optuna uses 0-based trial numbers. Using `iter{trial.number}` ensures:
- Dashboard shows Trial 0 -> corresponds to folder iter0
- No confusion when cross-referencing results
- Consistent indexing throughout the system
## Lessons Learned
### 1. TPE Sampler Seed Issue
**Problem**: When resuming a study, re-initializing TPESampler with a fixed seed causes the sampler to restart its random sequence, generating duplicate parameters.
**Solution**: Only set seed for NEW studies:
```python
if is_new_study:
sampler = TPESampler(seed=42, ...)
else:
sampler = TPESampler(...) # No seed for resume
```
### 2. Code Reuse Protocol
**Problem**: Embedding 500+ lines of Zernike code in `run_optimization.py` violates DRY principle.
**Solution**: Use centralized extractors:
```python
from optimization_engine.extractors import ZernikeExtractor
extractor = ZernikeExtractor(op2_file)
result = extractor.extract_relative("3", "2")
rms = result['relative_filtered_rms_nm']
```
### 3. Subcase Numbering
**Problem**: NX subcase numbers (1,2,3,4) don't match angle labels (20,40,60,90).
**Solution**: Use explicit mapping in config and translate:
```python
subcase_labels = {"1": "90deg", "2": "20deg", "3": "40deg", "4": "60deg"}
label_to_subcase = {v: k for k, v in subcase_labels.items()}
```
### 4. OP2 Data Validation
**Problem**: Corrupt OP2 files can have all-zero or unrealistic displacement values.
**Solution**: Validate before processing:
```python
unique_values = len(np.unique(disp_z))
if unique_values < 10:
raise RuntimeError("CORRUPT OP2: insufficient unique values")
if np.abs(disp_z).max() > 1e6:
raise RuntimeError("CORRUPT OP2: unrealistic displacement magnitude")
```
### 5. Reference Subcase for Relative Metrics
**Problem**: Which orientation to use as reference?
**Solution**: Use the lowest operational elevation (typically 20 deg) as reference. This makes higher elevations show positive relative WFE as gravity effects increase.
## ZernikeExtractor API Reference
### Basic Usage
```python
from optimization_engine.extractors import ZernikeExtractor
# Create extractor
extractor = ZernikeExtractor(
op2_path="path/to/results.op2",
bdf_path=None, # Auto-detect from same folder
displacement_unit="mm",
n_modes=50,
filter_orders=4
)
# Single subcase
result = extractor.extract_subcase("2")
# Returns: global_rms_nm, filtered_rms_nm, rms_filter_j1to3, aberrations...
# Relative between subcases
rel = extractor.extract_relative(target_subcase="3", reference_subcase="2")
# Returns: relative_filtered_rms_nm, relative_rms_filter_j1to3, ...
# All subcases with relative metrics
all_results = extractor.extract_all_subcases(reference_subcase="2")
```
### Available Metrics
| Method | Returns |
|--------|---------|
| `extract_subcase()` | global_rms_nm, filtered_rms_nm, rms_filter_j1to3, defocus_nm, astigmatism_rms_nm, coma_rms_nm, trefoil_rms_nm, spherical_nm |
| `extract_relative()` | relative_global_rms_nm, relative_filtered_rms_nm, relative_rms_filter_j1to3, relative aberrations |
| `extract_all_subcases()` | Dict of all subcases with both absolute and relative metrics |
## Configuration Template
```json
{
"study_name": "m1_mirror_optimization",
"design_variables": [
{
"name": "whiffle_min",
"expression_name": "whiffle_min",
"min": 35.0,
"max": 55.0,
"baseline": 40.55,
"units": "mm",
"enabled": true
}
],
"objectives": [
{
"name": "rel_filtered_rms_40_vs_20",
"extractor": "zernike_relative",
"extractor_config": {
"target_subcase": "3",
"reference_subcase": "2",
"metric": "relative_filtered_rms_nm"
},
"direction": "minimize",
"weight": 5.0,
"target": 4.0
}
],
"zernike_settings": {
"n_modes": 50,
"filter_low_orders": 4,
"displacement_unit": "mm",
"subcases": ["1", "2", "3", "4"],
"subcase_labels": {"1": "90deg", "2": "20deg", "3": "40deg", "4": "60deg"},
"reference_subcase": "2"
},
"optimization_settings": {
"sampler": "TPE",
"seed": 42,
"n_startup_trials": 15
}
}
```
## Version History
| Version | Key Changes |
|---------|-------------|
| V1-V6 | Initial development, various folder structures |
| V7 | HEEDS-style iteration folders, fresh model copies |
| V8 | Autonomous NX session management, but had embedded Zernike code |
| V9 | Clean ZernikeExtractor integration, fixed sampler seed, 0-based folders |