# Rigorous OPD-Based Zernike Analysis for Mirror Optimization

**Document Version**: 1.0
**Created**: 2024-12-22
**Author**: Atomizer Framework
**Status**: Active

---

## Executive Summary

This document describes a **rigorous Optical Path Difference (OPD)** method for computing Zernike wavefront error that correctly accounts for **lateral (X, Y) displacements** in addition to axial (Z) displacements.

**The Problem**: Standard Zernike analysis uses only Z-displacement at the original (x, y) node positions. When supports pinch the mirror or lateral forces cause in-plane deformation, nodes shift in X and Y. The standard method is **blind to this**, potentially leading to:
- Optimized designs that appear good but have poor actual optical performance
- Optimizer convergence to non-optimal solutions that "cheat" by distorting laterally

**The Solution**: The OPD method computes the true surface error by accounting for the fact that a laterally-displaced node should be compared against the parabola height **at its new (x+dx, y+dy) position**, not its original position.

---

## Table of Contents

1. [The Optical Physics Problem](#1-the-optical-physics-problem)
2. [Mathematical Formulation](#2-mathematical-formulation)
3. [When This Matters](#3-when-this-matters)
4. [Implementation Details](#4-implementation-details)
5. [Usage Guide](#5-usage-guide)
6. [Validation and Testing](#6-validation-and-testing)
7. [Migration Guide](#7-migration-guide)

---

## 1. The Optical Physics Problem

### 1.1 What Zernike Analysis Does

Zernike polynomials decompose a wavefront error surface into orthogonal modes:

```
W(r, θ) = Σ cⱼ Zⱼ(r, θ)
```

Where:
- `W` = wavefront error (nm)
- `cⱼ` = Zernike coefficient for mode j
- `Zⱼ` = Zernike polynomial (Noll indexing)

For a reflective mirror, the wavefront error is **twice** the surface error:
```
WFE = 2 × surface_error
```

### 1.2 Standard Method (Z-Only)

The standard approach:
1. Read node original positions `(x₀, y₀, z₀)` from BDF/DAT
2. Read displacement vector `(Δx, Δy, Δz)` from OP2
3. Compute surface error = `Δz` (Z-displacement only)
4. Compute WFE = `2 × Δz × nm_scale`
5. Fit Zernike at original coordinates `(x₀, y₀)`

```python
# Standard method (simplified)
for nid, (dx, dy, dz) in displacements:
    x, y, z = original_coords[nid]
    wfe = dz * 2 * nm_scale  # ONLY uses Z-displacement
    X.append(x)              # Original X
    Y.append(y)              # Original Y
    WFE.append(wfe)

coeffs = fit_zernike(X, Y, WFE)
```

### 1.3 The Problem: Lateral Displacement is Ignored

Consider a node on a parabolic mirror:
- **Original position**: `(x₀, y₀, z₀)` where `z₀ = -r₀²/(4f)` on the parabola
- **Deformed position**: `(x₀+Δx, y₀+Δy, z₀+Δz)`

**Question**: What is the true surface error?

**Standard method says**: surface_error = `Δz`

**But this is wrong!** If the node moved laterally to a new `(x, y)`, the ideal parabola has a **different** Z at that location. The node should be compared against:

```
z_expected = parabola(x₀+Δx, y₀+Δy) = -(x₀+Δx)² + (y₀+Δy)² / (4f)
```

Not against `z₀ = parabola(x₀, y₀)`.

### 1.4 Visual Example

```
                    Original parabola
                         ___
                      _/     \_
                    /           \
                   /      *A     \      A = original node at (x₀, y₀, z₀)
                  /     ↗  ↘      \     B = deformed position (x₀+Δx, y₀+Δy, z₀+Δz)
                 /     B    C      \    C = where node SHOULD be if staying on parabola
                /                   \
               /_____________________\

Standard method: error = z_B - z_A = Δz
                 (compares B to A vertically)

OPD method: error = z_B - z_C = Δz - Δz_parabola
            (compares B to where parabola is at B's (x,y))
```

---

## 2. Mathematical Formulation

### 2.1 Differential OPD Formulation

For a paraboloid with optical axis along Z:

```
z = -r² / (4f)    [concave mirror, vertex at origin]
```

Where:
- `r² = x² + y²`
- `f` = focal length

**Key Insight**: We can compute the **change** in parabola Z due to lateral movement:

```
Δz_parabola = z(x₀+Δx, y₀+Δy) - z(x₀, y₀)
            = -[(x₀+Δx)² + (y₀+Δy)²] / (4f) - [-( x₀² + y₀²) / (4f)]
            = -[r_def² - r₀²] / (4f)
            = -Δr² / (4f)
```

Where:
```
Δr² = r_def² - r₀² = (x₀+Δx)² + (y₀+Δy)² - x₀² - y₀²
    = 2·x₀·Δx + Δx² + 2·y₀·Δy + Δy²
```

### 2.2 True Surface Error

The true surface error is:

```
surface_error = Δz - Δz_parabola
              = Δz - (-Δr² / 4f)
              = Δz + Δr² / (4f)
```

**Interpretation**:
- If a node moves **outward** (larger r), it should also move in **-Z** to stay on the concave parabola
- If the FEA says it moved by `Δz`, but staying on the parabola requires `Δz_parabola`, the difference is the true error
- This corrects for the "false error" that the standard method counts when nodes shift laterally

### 2.3 Wavefront Error

```
WFE = 2 × surface_error × nm_scale
    = 2 × (Δz - Δz_parabola) × nm_scale
```

### 2.4 Zernike Fitting Coordinates

Another subtlety: the Zernike fit should use the **deformed** coordinates `(x₀+Δx, y₀+Δy)` rather than the original coordinates. This is because the WFE surface represents the error at the positions where the nodes **actually are** after deformation.

```python
# OPD method
X_fit = x0 + dx  # Deformed X
Y_fit = y0 + dy  # Deformed Y
WFE = surface_error * 2 * nm_scale

coeffs = fit_zernike(X_fit, Y_fit, WFE)
```

---

## 3. When This Matters

### 3.1 Magnitude Analysis

The correction term is:

```
Δz_parabola = -Δr² / (4f) ≈ -(2·x₀·Δx + 2·y₀·Δy) / (4f)    [ignoring Δx², Δy²]
            ≈ -(x₀·Δx + y₀·Δy) / (2f)
```

For a node at radius `r₀` with tangential displacement `Δ_tangential`:
- The correction is approximately: `r₀ · Δ_lateral / (2f)`

**Example**: Mirror with f = 5000 mm, outer radius = 400 mm
- Node at r = 400 mm shifts laterally by Δx = 0.001 mm (1 µm)
- Correction: `400 × 0.001 / (2 × 5000) = 0.00004 mm = 40 nm`

This is **significant** when typical WFE is in the 10-100 nm range!

### 3.2 Classification by Load Case

| Load Case | Lateral Disp. | Method Impact |
|-----------|--------------|---------------|
| **Axial support** (gravity in Z) | Very small | Minimal - both methods similar |
| **Lateral support** (gravity in X/Y) | **Large** | **Significant** - OPD method required |
| **Clamp/fixture forces** | Can be large locally | May be significant at pinch points |
| **Thermal** | Variable | Depends on thermal gradients |
| **Mirror cell deflection** | Variable | Check lateral displacement magnitude |

### 3.3 Diagnostic Thresholds

The `ZernikeOPDExtractor` provides lateral displacement statistics:

| Max Lateral Disp. | Recommendation |
|-------------------|----------------|
| > 10 µm | **CRITICAL**: OPD method required |
| 1 - 10 µm | **RECOMMENDED**: OPD method provides meaningful improvement |
| 0.1 - 1 µm | **OPTIONAL**: OPD method provides minor improvement |
| < 0.1 µm | **EQUIVALENT**: Both methods give essentially identical results |

---

## 4. Implementation Details

### 4.1 Module: `extract_zernike_opd.py`

Location: `optimization_engine/extractors/extract_zernike_opd.py`

**Key Functions**:

```python
def compute_true_opd(x0, y0, z0, dx, dy, dz, focal_length, concave=True):
    """
    Compute true surface error accounting for lateral displacement.

    Returns:
        x_def: Deformed X coordinates
        y_def: Deformed Y coordinates
        surface_error: True surface error (not just Δz)
        lateral_magnitude: |Δx, Δy| for diagnostics
    """
```

```python
def estimate_focal_length_from_geometry(x, y, z, concave=True):
    """
    Estimate parabola focal length by fitting z = a·r² + b.
    Focal length = 1 / (4·|a|)
    """
```

**Main Class**:

```python
class ZernikeOPDExtractor:
    """
    Rigorous OPD-based Zernike extractor.

    Key differences from ZernikeExtractor:
    - Uses deformed (x, y) coordinates for fitting
    - Computes surface error relative to parabola at deformed position
    - Provides lateral displacement diagnostics
    """
```

### 4.2 Algorithm Flow

```
1. Load geometry (BDF) and displacements (OP2)

2. For each node:
   a. Get original position: (x₀, y₀, z₀)
   b. Get displacement: (Δx, Δy, Δz)
   c. Compute deformed position: (x_def, y_def) = (x₀+Δx, y₀+Δy)
   d. Compute Δr² = r_def² - r₀²
   e. Compute Δz_parabola = -Δr² / (4f)   [for concave]
   f. Compute surface_error = Δz - Δz_parabola
   g. Store lateral_disp = √(Δx² + Δy²)

3. Convert to WFE: WFE = 2 × surface_error × nm_scale

4. Fit Zernike coefficients using (x_def, y_def, WFE)

5. Compute RMS metrics:
   - Global RMS = √(mean(WFE²))
   - Filtered RMS = √(mean((WFE - low_order_fit)²))
```

### 4.3 Focal Length Handling

The extractor can:
1. Use a **provided** focal length (most accurate)
2. **Auto-estimate** from geometry by fitting `z = a·r² + b`

Auto-estimation works well for clean parabolic meshes but may need manual override for:
- Off-axis parabolas
- Aspheric surfaces
- Meshes with significant manufacturing errors

```python
# Explicit focal length
extractor = ZernikeOPDExtractor(op2_file, focal_length=5000.0)

# Auto-estimate (default)
extractor = ZernikeOPDExtractor(op2_file, auto_estimate_focal=True)
```

---

## 5. Usage Guide

### 5.1 Quick Comparison Test

Run the test script to see how much the methods differ for your data:

```bash
conda activate atomizer
python test_zernike_opd_comparison.py
```

Output example:
```
--- Standard Method (Z-only) ---
  Global RMS:   171.65 nm
  Filtered RMS: 28.72 nm

--- Rigorous OPD Method ---
  Global RMS:   171.89 nm
  Filtered RMS: 29.15 nm

--- Difference (OPD - Standard) ---
  Filtered RMS: +0.43 nm (+1.5%)

--- Lateral Displacement ---
  Max:  0.156 µm
  RMS:  0.111 µm

>>> OPTIONAL: Small lateral displacements. OPD method provides minor improvement.
```

### 5.2 Using in Optimization

**For new studies**, use the OPD extractor:

```python
from optimization_engine.extractors import extract_zernike_opd_filtered_rms

def objective(trial):
    # ... parameter suggestion and FEA solve ...

    # Use OPD method instead of standard
    rms = extract_zernike_opd_filtered_rms(
        op2_file,
        subcase='20',
        focal_length=5000.0  # Optional: specify or let it auto-estimate
    )
    return rms
```

**In optimization config** (future enhancement):

```json
{
  "objectives": [
    {
      "name": "filtered_rms",
      "extractor": "zernike_opd",
      "extractor_config": {
        "subcase": "20",
        "metric": "filtered_rms_nm",
        "focal_length": 5000.0
      }
    }
  ]
}
```

### 5.3 Visualization with Insights

Generate the comparison insight for a study:

```bash
python -m optimization_engine.insights generate studies/my_study --type zernike_opd_comparison
```

This creates an HTML visualization showing:
1. **Lateral displacement map** - Where pinching/lateral deformation occurs
2. **WFE surface** - Using the rigorous OPD method
3. **Comparison table** - Quantitative difference between methods
4. **Recommendation** - Whether OPD method is needed for your study

### 5.4 API Reference

```python
from optimization_engine.extractors import (
    # Main extractor class
    ZernikeOPDExtractor,

    # Convenience functions
    extract_zernike_opd,           # Full metrics dict
    extract_zernike_opd_filtered_rms,  # Just the filtered RMS (float)
    compare_zernike_methods,       # Compare standard vs OPD
)

# Full extraction with all metrics
result = extract_zernike_opd(op2_file, subcase='20')
# Returns: {
#   'filtered_rms_nm': float,
#   'global_rms_nm': float,
#   'max_lateral_disp_um': float,
#   'rms_lateral_disp_um': float,
#   'focal_length_used': float,
#   'astigmatism_rms_nm': float,
#   'coma_rms_nm': float,
#   ...
# }

# Just the primary metric for optimization
rms = extract_zernike_opd_filtered_rms(op2_file, subcase='20')

# Compare both methods
comparison = compare_zernike_methods(op2_file, subcase='20')
# Returns: {
#   'standard_method': {'filtered_rms_nm': ...},
#   'opd_method': {'filtered_rms_nm': ...},
#   'delta': {'filtered_rms_nm': ..., 'percent_difference_filtered': ...},
#   'lateral_displacement': {'max_um': ..., 'rms_um': ...},
#   'recommendation': str
# }
```

---

## 6. Validation and Testing

### 6.1 Analytical Test Case

For a simple test: apply a known lateral displacement and verify the correction.

**Setup**:
- Parabola: f = 5000 mm
- Node at (x₀, y₀) = (400, 0) mm, so r₀ = 400 mm
- Apply uniform X-displacement: Δx = 0.01 mm, Δy = 0, Δz = 0

**Expected correction**:
```
Δr² = (400.01)² + 0² - 400² - 0² = 8.0001 mm²
Δz_parabola = -8.0001 / (4 × 5000) = -0.0004 mm = -400 nm (surface)
WFE_correction = 2 × 400 nm = 800 nm
```

**Standard method**: WFE = 2 × Δz × 1e6 = 0 nm
**OPD method**: WFE = 2 × (0 - (-0.0004)) × 1e6 = 800 nm

The OPD method correctly identifies that a purely lateral displacement **does** affect the wavefront!

### 6.2 Sanity Checks

The OPD method should:
1. Give **identical** results to standard method when Δx = Δy = 0 everywhere
2. Show **larger** WFE when nodes move outward laterally (positive Δr²)
3. Show **smaller** WFE when nodes move inward laterally (negative Δr²)
4. Scale with 1/f (larger effect for faster mirrors)

### 6.3 Running the Test

```bash
conda activate atomizer
python test_zernike_opd_comparison.py
```

---

## 7. Migration Guide

### 7.1 For Existing Studies

1. **Run comparison test** on a few representative iterations
2. **Check the difference** - if > 5%, consider re-optimizing
3. **For lateral support studies** - strongly recommend re-optimization with OPD method

### 7.2 For New Studies

1. **Use OPD method by default** - it's never worse than standard
2. **Specify focal length** if known (more accurate than auto-estimate)
3. **Monitor lateral displacement** in the insight reports

### 7.3 Code Changes

**Before** (standard method):
```python
from optimization_engine.extractors import extract_zernike_filtered_rms

rms = extract_zernike_filtered_rms(op2_file, subcase='20')
```

**After** (OPD method):
```python
from optimization_engine.extractors import extract_zernike_opd_filtered_rms

rms = extract_zernike_opd_filtered_rms(op2_file, subcase='20', focal_length=5000.0)
```

---

## Appendix A: Derivation Details

### A.1 Full Derivation of Δz_parabola

For a concave paraboloid: `z = -r²/(4f) = -(x² + y²)/(4f)`

Original position: `z₀ = -(x₀² + y₀²)/(4f)`
Deformed position: `z_expected = -((x₀+Δx)² + (y₀+Δy)²)/(4f)`

```
z_expected - z₀ = -[(x₀+Δx)² + (y₀+Δy)² - x₀² - y₀²] / (4f)
                = -[x₀² + 2x₀Δx + Δx² + y₀² + 2y₀Δy + Δy² - x₀² - y₀²] / (4f)
                = -[2x₀Δx + Δx² + 2y₀Δy + Δy²] / (4f)
                = -[r_def² - r₀²] / (4f)
                = -Δr² / (4f)
```

This is `Δz_parabola` - the Z change required to stay on the ideal parabola.

### A.2 Sign Convention

For a **concave** mirror (typical telescope primary):
- Surface curves toward -Z (vertex is the highest point)
- `z = -r²/(4f)` (negative coefficient)
- Moving outward (Δr² > 0) requires moving in -Z direction
- `Δz_parabola = -Δr²/(4f)` is negative for outward movement

For a **convex** mirror:
- Surface curves toward +Z
- `z = +r²/(4f)` (positive coefficient)
- `Δz_parabola = +Δr²/(4f)` is positive for outward movement

The `concave` parameter in the code handles this sign flip.

---

## Appendix B: Files Reference

| File | Purpose |
|------|---------|
| `optimization_engine/extractors/extract_zernike_opd.py` | Main OPD extractor implementation |
| `optimization_engine/extractors/extract_zernike.py` | Standard (Z-only) extractor |
| `optimization_engine/insights/zernike_opd_comparison.py` | Visualization insight |
| `test_zernike_opd_comparison.py` | Quick test script |

### Related Documentation

| Document | Purpose |
|----------|---------|
| [ZERNIKE_FUNDAMENTALS.md](ZERNIKE_FUNDAMENTALS.md) | General Zernike usage, RMS calculation, multi-subcase analysis |
| [00_INDEX.md](00_INDEX.md) | Physics documentation index |
| `.claude/skills/modules/extractors-catalog.md` | Quick extractor lookup |
| `.claude/skills/modules/insights-catalog.md` | Quick insight lookup |
| `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` | Extractor specifications (E8-E10, E20-E21) |
| `docs/protocols/system/SYS_17_STUDY_INSIGHTS.md` | Insight specifications |

---

## Appendix C: Glossary

| Term | Definition |
|------|------------|
| **OPD** | Optical Path Difference - the path length difference experienced by light rays |
| **WFE** | Wavefront Error - deviation of actual wavefront from ideal (WFE = 2 × surface error for reflection) |
| **Zernike polynomials** | Orthogonal basis functions for representing wavefronts over a circular aperture |
| **Noll index** | Standard optical indexing scheme for Zernike modes (j=1 is piston, j=4 is defocus, etc.) |
| **Filtered RMS** | RMS after removing low-order modes (piston, tip, tilt, defocus) that can be corrected by alignment |
| **Lateral displacement** | In-plane (X, Y) movement of nodes, as opposed to axial (Z) movement |
| **Focal length** | Distance from vertex to focus for a parabola; f = R/(2) where R is vertex radius of curvature |

---

*Document maintained by Atomizer Framework. Last updated: 2024-12-22*
