# Zernike Wavefront Analysis Integration

This document describes how to use Atomizer's Zernike analysis capabilities for telescope mirror optimization.

## Overview

Atomizer includes a full Zernike polynomial decomposition system for analyzing wavefront errors (WFE) in telescope mirror FEA simulations. The system:

- Extracts nodal displacements from NX Nastran OP2 files
- Fits Zernike polynomials using Noll indexing (optical standard)
- Computes RMS metrics (global and filtered)
- Analyzes individual aberrations (astigmatism, coma, trefoil, etc.)
- Supports multi-subcase analysis (different gravity orientations)

## Quick Start

### Simple Extraction

```python
from optimization_engine.extractors import extract_zernike_from_op2

# Extract Zernike metrics for a single subcase
result = extract_zernike_from_op2(
    op2_file="model-solution_1.op2",
    subcase="20"  # 20 degree elevation
)

print(f"Global RMS: {result['global_rms_nm']:.2f} nm")
print(f"Filtered RMS: {result['filtered_rms_nm']:.2f} nm")
print(f"Astigmatism: {result['astigmatism_rms_nm']:.2f} nm")
```

### In Optimization Objective

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

# Create objective function
zernike_obj = create_zernike_objective(
    op2_finder=lambda: sim_dir / "model-solution_1.op2",
    subcase="20",
    metric="filtered_rms_nm"
)

# Use in Optuna trial
def objective(trial):
    # ... suggest parameters ...
    # ... run simulation ...

    rms = zernike_obj()
    return rms
```

## RMS Calculation Method

**IMPORTANT**: Atomizer uses the correct surface-based RMS calculation matching optical standards:

```python
# Global RMS = sqrt(mean(W^2)) - RMS of actual WFE surface values
global_rms = sqrt(mean(W_nm ** 2))

# Filtered RMS = sqrt(mean(W_residual^2))
# where W_residual = W_nm - Z[:, :4] @ coeffs[:4] (low-order fit subtracted)
filtered_rms = sqrt(mean(W_residual ** 2))
```

This is **different** from summing Zernike coefficients! The RMS is computed from the actual WFE surface values, not from `sqrt(sum(coeffs^2))`.

## Available Metrics

### RMS Metrics
| Metric | Description |
|--------|-------------|
| `global_rms_nm` | RMS of entire WFE surface: `sqrt(mean(W^2))` |
| `filtered_rms_nm` | RMS after removing modes 1-4 (piston, tip, tilt, defocus) |
| `rms_filter_j1to3_nm` | RMS after removing only modes 1-3 (keeps defocus) - "optician workload" |

### Aberration Magnitudes
| Metric | Zernike Modes | Description |
|--------|--------------|-------------|
| `defocus_nm` | J4 | Focus error |
| `astigmatism_rms_nm` | J5 + J6 | Combined astigmatism |
| `coma_rms_nm` | J7 + J8 | Combined coma |
| `trefoil_rms_nm` | J9 + J10 | Combined trefoil |
| `spherical_nm` | J11 | Primary spherical |

## Multi-Subcase Analysis

For telescope mirrors, gravity orientation affects surface shape. Standard subcases:

| Subcase | Description |
|---------|-------------|
| 20 | Low elevation (operational) |
| 40 | Mid-low elevation |
| 60 | Mid-high elevation |
| 90 | Horizontal (polishing orientation) |

### Extract All Subcases

```python
from optimization_engine.extractors import ZernikeExtractor

extractor = ZernikeExtractor("model.op2")
results = extractor.extract_all_subcases(reference_subcase="20")

for label, metrics in results.items():
    print(f"Subcase {label}: {metrics['filtered_rms_nm']:.1f} nm")
```

### Relative Analysis

Compare deformation between orientations:

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

# Minimize deformation at 20 deg relative to polishing position (90 deg)
relative_obj = create_relative_zernike_objective(
    op2_finder=lambda: sim_dir / "model.op2",
    target_subcase="20",
    reference_subcase="90"
)

relative_rms = relative_obj()
```

## Optimization Configuration

### Example: Single Objective (Filtered RMS)

```json
{
  "objectives": [
    {
      "name": "filtered_rms",
      "direction": "minimize",
      "extractor": "zernike",
      "extractor_config": {
        "subcase": "20",
        "metric": "filtered_rms_nm"
      }
    }
  ]
}
```

### Example: Multi-Objective (RMS + Mass)

```json
{
  "objectives": [
    {
      "name": "filtered_rms_20deg",
      "direction": "minimize",
      "extractor": "zernike",
      "extractor_config": {
        "subcase": "20",
        "metric": "filtered_rms_nm"
      }
    },
    {
      "name": "mass",
      "direction": "minimize",
      "extractor": "mass_from_expression"
    }
  ],
  "optimization_settings": {
    "sampler": "NSGA-II",
    "protocol": 11
  }
}
```

### Example: Constrained (Stress + Aberration Limits)

```json
{
  "constraints": [
    {
      "name": "astigmatism_limit",
      "type": "upper_bound",
      "threshold": 50.0,
      "extractor": "zernike",
      "extractor_config": {
        "subcase": "90",
        "metric": "astigmatism_rms_nm"
      }
    }
  ]
}
```

## Advanced: ZernikeObjectiveBuilder

For complex multi-subcase objectives:

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

builder = ZernikeObjectiveBuilder(
    op2_finder=lambda: sim_dir / "model.op2"
)

# Weight operational positions more heavily
builder.add_subcase_objective("20", "filtered_rms_nm", weight=1.0)
builder.add_subcase_objective("40", "filtered_rms_nm", weight=0.5)
builder.add_subcase_objective("60", "filtered_rms_nm", weight=0.5)

# Create combined objective (weighted sum)
objective = builder.build_weighted_sum()

# Or: worst-case across subcases
worst_case_obj = builder.build_max()
```

## Zernike Settings

### Configuration Options

| Setting | Default | Description |
|---------|---------|-------------|
| `n_modes` | 50 | Number of Zernike modes to fit |
| `filter_orders` | 4 | Low-order modes to filter (1-4 = piston through defocus) |
| `displacement_unit` | "mm" | Unit of displacement in OP2 ("mm", "m", "um", "nm") |

### Unit Conversions

Wavefront error (WFE) is computed as:

```
WFE_nm = 2 * displacement * unit_conversion
```

Where `unit_conversion` converts to nanometers:
- mm: 1e6
- m: 1e9
- um: 1e3

The factor of 2 accounts for the optical convention (surface error doubles as wavefront error for reflection).

## NX Nastran Setup

### Required Subcases

Your NX Nastran model should have subcases for each gravity orientation:

```
SUBCASE 20
  SUBTITLE=20 deg elevation
  LOAD = ...

SUBCASE 40
  SUBTITLE=40 deg elevation
  LOAD = ...
```

The extractor identifies subcases by:
1. Numeric value in SUBTITLE (preferred)
2. SUBCASE ID number

### Output Requests

Ensure displacement output is requested:

```
SET 999 = ALL
DISPLACEMENT(SORT1,REAL) = 999
```

## Migration from Legacy Scripts

If you were using `zernike_Post_Script_NX.py`:

| Old Approach | Atomizer Equivalent |
|--------------|---------------------|
| Manual OP2 parsing | `ZernikeExtractor` |
| `compute_zernike_coeffs_chunked()` | `compute_zernike_coefficients()` |
| `write_exp_file()` | Configure as objective/constraint |
| HTML reports | Dashboard visualization (TBD) |
| RMS log CSV | Optuna database + export |

### Key Differences

1. **Integration**: Zernike is now an extractor like displacement/stress
2. **Optimization**: Direct use as objectives/constraints in Optuna
3. **Multi-objective**: Native NSGA-II support for RMS + mass Pareto optimization
4. **Neural Acceleration**: Can train surrogate on Zernike metrics (Protocol 12)

## Example Study Structure

```
studies/
  mirror_optimization/
    1_setup/
      optimization_config.json
      model/
        ASSY_M1.prt
        ASSY_M1_assyfem1.afm
        ASSY_M1_assyfem1_sim1.sim
    2_results/
      study.db
      zernike_analysis/
        trial_001_zernike.json
        trial_002_zernike.json
        ...
    run_optimization.py
```

## See Also

### Related Physics Documentation

- [ZERNIKE_OPD_METHOD.md](ZERNIKE_OPD_METHOD.md) - **Rigorous OPD method for lateral displacement correction** (critical for lateral support optimization)

### Protocol Documentation

- `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` - Extractor specifications (E8-E10: Standard Zernike, E20-E21: OPD method)
- `docs/protocols/system/SYS_17_STUDY_INSIGHTS.md` - Insight specifications (`zernike_wfe`, `zernike_opd_comparison`)

### Skill Modules (Quick Lookup)

- `.claude/skills/modules/extractors-catalog.md` - Quick extractor reference
- `.claude/skills/modules/insights-catalog.md` - Quick insight reference

### Code Implementation

- [optimization_engine/extractors/extract_zernike.py](../../optimization_engine/extractors/extract_zernike.py) - Standard Zernike extractor
- [optimization_engine/extractors/extract_zernike_opd.py](../../optimization_engine/extractors/extract_zernike_opd.py) - **OPD-based extractor** (use for lateral supports)
- [optimization_engine/extractors/zernike_helpers.py](../../optimization_engine/extractors/zernike_helpers.py) - Helper functions and objective builders

### Example Configurations

- [examples/optimization_config_zernike_mirror.json](../examples/optimization_config_zernike_mirror.json) - Full example configuration
