# Zernike Optimization Module

**Last Updated**: December 5, 2025
**Version**: 1.0
**Type**: Optional Module

This module provides specialized guidance for telescope mirror and optical surface optimization using Zernike polynomial decomposition.

---

## When to Load

- User mentions "telescope", "mirror", "optical", "wavefront"
- Optimization involves surface deformation analysis
- Need to extract Zernike coefficients from FEA results
- Working with multi-subcase elevation angle comparisons

---

## Zernike Extractors (E8-E10)

| ID | Extractor | Function | Input | Output | Use Case |
|----|-----------|----------|-------|--------|----------|
| E8 | **Zernike WFE** | `extract_zernike_from_op2()` | `.op2` + `.bdf` | nm | Single subcase wavefront error |
| E9 | **Zernike Relative** | `extract_zernike_relative_rms()` | `.op2` + `.bdf` | nm | Compare target vs reference subcase |
| E10 | **Zernike Helpers** | `ZernikeObjectiveBuilder` | `.op2` | nm | Multi-subcase optimization builder |

---

## E8: Single Subcase Zernike Extraction

Extract Zernike coefficients and RMS metrics for a single subcase (e.g., one elevation angle).

```python
from optimization_engine.extractors.extract_zernike import extract_zernike_from_op2

# Extract Zernike coefficients and RMS metrics for a single subcase
result = extract_zernike_from_op2(
    op2_file,
    bdf_file=None,  # Auto-detect from op2 location
    subcase="20",   # Subcase label (e.g., "20" = 20 deg elevation)
    displacement_unit="mm"
)

global_rms = result['global_rms_nm']       # Total surface RMS in nm
filtered_rms = result['filtered_rms_nm']   # RMS with low orders removed
coefficients = result['coefficients']       # List of 50 Zernike coefficients
```

**Return Dictionary**:
```python
{
    'global_rms_nm': 45.2,              # Total surface RMS (nm)
    'filtered_rms_nm': 12.8,            # RMS with J1-J4 (piston, tip, tilt, defocus) removed
    'coefficients': [0.0, 12.3, ...],   # 50 Zernike coefficients (Noll indexing)
    'n_nodes': 5432,                    # Number of surface nodes
    'rms_per_mode': {...}               # RMS contribution per Zernike mode
}
```

**When to Use**:
- Single elevation angle analysis
- Polishing orientation (zenith) wavefront error
- Absolute surface quality metrics

---

## E9: Relative RMS Between Subcases

Compare wavefront error between two subcases (e.g., 40° vs 20° reference).

```python
from optimization_engine.extractors.extract_zernike import extract_zernike_relative_rms

# Compare wavefront error between subcases (e.g., 40 deg vs 20 deg reference)
result = extract_zernike_relative_rms(
    op2_file,
    bdf_file=None,
    target_subcase="40",      # Target orientation
    reference_subcase="20",   # Reference (usually polishing orientation)
    displacement_unit="mm"
)

relative_rms = result['relative_filtered_rms_nm']  # Differential WFE in nm
delta_coeffs = result['delta_coefficients']        # Coefficient differences
```

**Return Dictionary**:
```python
{
    'relative_filtered_rms_nm': 8.7,    # Differential WFE (target - reference)
    'delta_coefficients': [...],         # Coefficient differences
    'target_rms_nm': 52.3,              # Target subcase absolute RMS
    'reference_rms_nm': 45.2,           # Reference subcase absolute RMS
    'improvement_percent': -15.7        # Negative = worse than reference
}
```

**When to Use**:
- Comparing performance across elevation angles
- Minimizing deformation relative to polishing orientation
- Multi-angle telescope mirror optimization

---

## E10: Multi-Subcase Objective Builder

Build objectives for multiple subcases in a single extractor (most efficient for complex optimization).

```python
from optimization_engine.extractors.zernike_helpers import ZernikeObjectiveBuilder

# Build objectives for multiple subcases in one extractor
builder = ZernikeObjectiveBuilder(
    op2_finder=lambda: model_dir / "ASSY_M1-solution_1.op2"
)

# Add relative objectives (target vs reference)
builder.add_relative_objective(
    "40", "20",  # 40° vs 20° reference
    metric="relative_filtered_rms_nm",
    weight=5.0
)
builder.add_relative_objective(
    "60", "20",  # 60° vs 20° reference
    metric="relative_filtered_rms_nm",
    weight=5.0
)

# Add absolute objective for polishing orientation
builder.add_subcase_objective(
    "90",  # Zenith (polishing orientation)
    metric="rms_filter_j1to3",  # Only remove piston, tip, tilt
    weight=1.0
)

# Evaluate all at once (efficient - parses OP2 only once)
results = builder.evaluate_all()
# Returns: {'rel_40_vs_20': 4.2, 'rel_60_vs_20': 8.7, 'rms_90': 15.3}
```

**When to Use**:
- Multi-objective telescope optimization
- Multiple elevation angles to optimize
- Weighted combination of absolute and relative WFE

---

## Zernike Modes Reference

| Noll Index | Name | Physical Meaning | Correctability |
|------------|------|------------------|----------------|
| J1 | Piston | Constant offset | Easily corrected |
| J2 | Tip | X-tilt | Easily corrected |
| J3 | Tilt | Y-tilt | Easily corrected |
| J4 | Defocus | Power error | Easily corrected |
| J5 | Astigmatism (0°) | Cylindrical error | Correctable |
| J6 | Astigmatism (45°) | Cylindrical error | Correctable |
| J7 | Coma (x) | Off-axis aberration | Harder to correct |
| J8 | Coma (y) | Off-axis aberration | Harder to correct |
| J9-J10 | Trefoil | Triangular error | Hard to correct |
| J11+ | Higher order | Complex aberrations | Very hard to correct |

**Filtering Convention**:
- `filtered_rms`: Removes J1-J4 (piston, tip, tilt, defocus) - standard
- `rms_filter_j1to3`: Removes only J1-J3 (keeps defocus) - for focus-sensitive applications

---

## Common Zernike Optimization Patterns

### Pattern 1: Minimize Relative WFE Across Elevations

```python
# Objective: Minimize max relative WFE across all elevation angles
objectives = [
    {"name": "rel_40_vs_20", "goal": "minimize"},
    {"name": "rel_60_vs_20", "goal": "minimize"},
]

# Use weighted sum or multi-objective
def objective(trial):
    results = builder.evaluate_all()
    return (results['rel_40_vs_20'], results['rel_60_vs_20'])
```

### Pattern 2: Single Elevation + Mass

```python
# Objective: Minimize WFE at 45° while minimizing mass
objectives = [
    {"name": "wfe_45", "goal": "minimize"},  # Wavefront error
    {"name": "mass", "goal": "minimize"},     # Mirror mass
]
```

### Pattern 3: Weighted Multi-Angle

```python
# Weighted combination of multiple angles
def combined_wfe(trial):
    results = builder.evaluate_all()
    weighted_wfe = (
        5.0 * results['rel_40_vs_20'] +
        5.0 * results['rel_60_vs_20'] +
        1.0 * results['rms_90']
    )
    return weighted_wfe
```

---

## Telescope Mirror Study Configuration

```json
{
  "study_name": "m1_mirror_optimization",
  "description": "Minimize wavefront error across elevation angles",

  "objectives": [
    {
      "name": "wfe_40_vs_20",
      "goal": "minimize",
      "unit": "nm",
      "extraction": {
        "action": "extract_zernike_relative_rms",
        "params": {
          "target_subcase": "40",
          "reference_subcase": "20"
        }
      }
    }
  ],

  "simulation": {
    "analysis_types": ["static"],
    "subcases": ["20", "40", "60", "90"],
    "solution_name": null
  }
}
```

---

## Performance Considerations

1. **Parse OP2 Once**: Use `ZernikeObjectiveBuilder` to parse the OP2 file only once per trial
2. **Subcase Labels**: Match exact subcase labels from NX simulation
3. **Node Selection**: Zernike extraction uses surface nodes only (auto-detected from BDF)
4. **Memory**: Large meshes (>50k nodes) may require chunked processing

---

## Troubleshooting

| Symptom | Cause | Solution |
|---------|-------|----------|
| "Subcase not found" | Wrong subcase label | Check NX .sim for exact labels |
| High J1-J4 coefficients | Rigid body motion not constrained | Check boundary conditions |
| NaN in coefficients | Insufficient nodes for polynomial order | Reduce max Zernike order |
| Inconsistent RMS | Different node sets per subcase | Verify mesh consistency |
| "Billion nm" RMS values | Node merge failed in AFEM | Check `MergeOccurrenceNodes = True` |
| Corrupt OP2 data | All-zero displacements | Validate OP2 before processing |

---

## Assembly FEM (AFEM) Structure for Mirrors

Telescope mirror assemblies in NX typically consist of:

```
ASSY_M1.prt                      # Master assembly part
ASSY_M1_assyfem1.afm             # Assembly FEM container
ASSY_M1_assyfem1_sim1.sim        # Simulation file (solve this)
M1_Blank.prt                     # Mirror blank part
M1_Blank_fem1.fem                # Mirror blank mesh
M1_Vertical_Support_Skeleton.prt # Support structure
```

**Key Point**: Expressions in master `.prt` propagate through assembly → AFEM updates automatically.

---

## Multi-Subcase Gravity Analysis

For telescope mirrors, analyze multiple gravity orientations:

| Subcase | Elevation Angle | Purpose |
|---------|-----------------|---------|
| 1 | 90° (zenith) | Polishing orientation - manufacturing reference |
| 2 | 20° | Low elevation - reference for relative metrics |
| 3 | 40° | Mid-low elevation |
| 4 | 60° | Mid-high elevation |

**CRITICAL**: NX subcase numbers don't always match angle labels! Use explicit mapping:

```json
"subcase_labels": {
  "1": "90deg",
  "2": "20deg",
  "3": "40deg",
  "4": "60deg"
}
```

---

## Lessons Learned (M1 Mirror V1-V9)

### 1. TPE Sampler Seed Issue

**Problem**: Resuming study with fixed seed causes duplicate parameters.

**Solution**:
```python
if is_new_study:
    sampler = TPESampler(seed=42)
else:
    sampler = TPESampler()  # No seed for resume
```

### 2. OP2 Data Validation

**Always 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")
```

### 3. Reference Subcase Selection

Use lowest operational elevation (typically 20°) as reference. Higher elevations show positive relative WFE as gravity effects increase.

### 4. Optical Convention

For mirror surface to wavefront error:
```python
WFE = 2 * surface_displacement  # Reflection doubles path difference
wfe_nm = 2.0 * displacement_mm * 1e6  # Convert mm to nm
```

---

## Typical Mirror Design Variables

| Parameter | Description | Typical Range |
|-----------|-------------|---------------|
| `whiffle_min` | Whiffle tree minimum dimension | 35-55 mm |
| `whiffle_outer_to_vertical` | Whiffle arm angle | 68-80 deg |
| `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 |

---

## Cross-References

- **Extractor Catalog**: [extractors-catalog module](./extractors-catalog.md)
- **System Protocol**: [SYS_12_EXTRACTOR_LIBRARY](../../docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md)
- **Core Skill**: [study-creation-core](../core/study-creation-core.md)
