feat: Add OPD method support to Zernike visualization with Standard/OPD toggle

Major improvements to Zernike WFE visualization:

- Add ZernikeDashboardInsight: Unified dashboard with all orientations (40°, 60°, 90°)
  on one page with light theme and executive summary
- Add OPD method toggle: Switch between Standard (Z-only) and OPD (X,Y,Z) methods
  in ZernikeWFEInsight with interactive buttons
- Add lateral displacement maps: Visualize X,Y displacement for each orientation
- Add displacement component views: Toggle between WFE, ΔX, ΔY, ΔZ in relative views
- Add metrics comparison table showing both methods side-by-side

New extractors:
- extract_zernike_figure.py: ZernikeOPDExtractor using BDF geometry interpolation
- extract_zernike_opd.py: Parabola-based OPD with focal length

Key finding: OPD method gives 8-11% higher WFE values than Standard method
(more conservative/accurate for surfaces with lateral displacement under gravity)

Documentation updates:
- SYS_12: Added E22 ZernikeOPD as recommended method
- SYS_16: Added ZernikeDashboard, updated ZernikeWFE with OPD features
- Cheatsheet: Added Zernike method comparison table

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 21:03:19 -05:00
parent d089003ced
commit d19fc39a2a
19 changed files with 8117 additions and 396 deletions

View File

@@ -57,6 +57,10 @@ The Extractor Library provides centralized, reusable functions for extracting ph
| E18 | Modal Mass | `extract_modal_mass()` | .f06 | kg |
| **Phase 4 (2025-12-19)** | | | | |
| E19 | Part Introspection | `introspect_part()` | .prt | dict |
| **Phase 5 (2025-12-22)** | | | | |
| E20 | Zernike Analytic (Parabola) | `extract_zernike_analytic()` | .op2 + .bdf | nm |
| E21 | Zernike Method Comparison | `compare_zernike_methods()` | .op2 + .bdf | dict |
| E22 | **Zernike OPD (RECOMMENDED)** | `extract_zernike_opd()` | .op2 + .bdf | nm |
---
@@ -326,6 +330,161 @@ results = builder.evaluate_all()
# Returns: {'rel_40_vs_20': 4.2, 'rel_60_vs_20': 8.7, 'rms_90': 15.3}
```
### E20: Zernike Analytic (Parabola-Based with Lateral Correction)
**Module**: `optimization_engine.extractors.extract_zernike_opd`
Uses an analytical parabola formula to account for lateral (X, Y) displacements. Requires knowing the focal length.
**Use when**: You know the optical prescription and want to compare against theoretical parabola.
```python
from optimization_engine.extractors import extract_zernike_analytic, ZernikeAnalyticExtractor
# Full extraction with lateral displacement diagnostics
result = extract_zernike_analytic(
op2_file,
subcase="20",
focal_length=5000.0, # Required for analytic method
)
# Class-based usage
extractor = ZernikeAnalyticExtractor(op2_file, focal_length=5000.0)
result = extractor.extract_subcase('20')
```
### E21: Zernike Method Comparison
**Module**: `optimization_engine.extractors.extract_zernike_opd`
Compare standard (Z-only) vs analytic (parabola) methods.
```python
from optimization_engine.extractors import compare_zernike_methods
comparison = compare_zernike_methods(op2_file, subcase="20", focal_length=5000.0)
print(comparison['recommendation'])
```
### E22: Zernike OPD (RECOMMENDED - Most Rigorous)
**Module**: `optimization_engine.extractors.extract_zernike_figure`
**MOST RIGOROUS METHOD** for computing WFE. Uses the actual BDF geometry (filtered to OP2 nodes) as the reference surface instead of assuming a parabolic shape.
**Advantages over E20 (Analytic)**:
- No need to know focal length or optical prescription
- Works with **any surface shape**: parabola, hyperbola, asphere, freeform
- Uses the actual mesh geometry as the "ideal" surface reference
- Interpolates `z_figure` at deformed `(x+dx, y+dy)` position for true OPD
**How it works**:
1. Load BDF geometry for nodes present in OP2 (figure surface nodes)
2. Build 2D interpolator `z_figure(x, y)` from undeformed coordinates
3. For each deformed node at `(x0+dx, y0+dy, z0+dz)`:
- Interpolate `z_figure` at the deformed (x,y) position
- Surface error = `(z0 + dz) - z_interpolated`
4. Fit Zernike polynomials to the surface error map
```python
from optimization_engine.extractors import (
ZernikeOPDExtractor,
extract_zernike_opd,
extract_zernike_opd_filtered_rms,
)
# Full extraction with diagnostics
result = extract_zernike_opd(op2_file, subcase="20")
# Returns: {
# 'global_rms_nm': float,
# 'filtered_rms_nm': float,
# 'max_lateral_displacement_um': float,
# 'rms_lateral_displacement_um': float,
# 'coefficients': list, # 50 Zernike coefficients
# 'method': 'opd',
# 'figure_file': 'BDF (filtered to OP2)',
# ...
# }
# Simple usage for optimization objective
rms = extract_zernike_opd_filtered_rms(op2_file, subcase="20")
# Class-based for multi-subcase analysis
extractor = ZernikeOPDExtractor(op2_file)
results = extractor.extract_all_subcases()
```
#### Relative WFE (CRITICAL for Optimization)
**Use `extract_relative()` for computing relative WFE between subcases!**
> **BUG WARNING (V10 Fix - 2025-12-22)**: The WRONG way to compute relative WFE is:
> ```python
> # ❌ WRONG: Difference of RMS values
> result_40 = extractor.extract_subcase("3")
> result_ref = extractor.extract_subcase("2")
> rel_40 = abs(result_40['filtered_rms_nm'] - result_ref['filtered_rms_nm']) # WRONG!
> ```
>
> This computes `|RMS(WFE_40) - RMS(WFE_20)|`, which is NOT the same as `RMS(WFE_40 - WFE_20)`.
> The difference can be **3-4x lower** than the correct value, leading to false "too good to be true" results.
**The CORRECT approach uses `extract_relative()`:**
```python
# ✅ CORRECT: Computes node-by-node WFE difference, then fits Zernike, then RMS
extractor = ZernikeOPDExtractor(op2_file)
rel_40 = extractor.extract_relative("3", "2") # 40 deg vs 20 deg
rel_60 = extractor.extract_relative("4", "2") # 60 deg vs 20 deg
rel_90 = extractor.extract_relative("1", "2") # 90 deg vs 20 deg
# Returns: {
# 'target_subcase': '3',
# 'reference_subcase': '2',
# 'method': 'figure_opd_relative',
# 'relative_global_rms_nm': float, # RMS of the difference field
# 'relative_filtered_rms_nm': float, # Use this for optimization!
# 'relative_rms_filter_j1to3': float, # For manufacturing/optician workload
# 'max_lateral_displacement_um': float,
# 'rms_lateral_displacement_um': float,
# 'delta_coefficients': list, # Zernike coeffs of difference
# }
# Use in optimization objectives:
objectives = {
'rel_filtered_rms_40_vs_20': rel_40['relative_filtered_rms_nm'],
'rel_filtered_rms_60_vs_20': rel_60['relative_filtered_rms_nm'],
'mfg_90_optician_workload': rel_90['relative_rms_filter_j1to3'],
}
```
**Mathematical Difference**:
```
WRONG: |RMS(WFE_40) - RMS(WFE_20)| = |6.14 - 8.13| = 1.99 nm ← FALSE!
CORRECT: RMS(WFE_40 - WFE_20) = RMS(diff_field) = 6.59 nm ← TRUE!
```
The Standard `ZernikeExtractor` also has `extract_relative()` if you don't need the OPD method:
```python
from optimization_engine.extractors import ZernikeExtractor
extractor = ZernikeExtractor(op2_file, n_modes=50, filter_orders=4)
rel_40 = extractor.extract_relative("3", "2") # Z-only method
```
**Backwards compatibility**: The old names (`ZernikeFigureExtractor`, `extract_zernike_figure`, `extract_zernike_figure_rms`) still work but are deprecated.
**When to use which Zernike method**:
| Method | Class | When to Use | Assumptions |
|--------|-------|-------------|-------------|
| Standard (E8) | `ZernikeExtractor` | Quick analysis, negligible lateral displacement | Z-only at original (x,y) |
| Analytic (E20) | `ZernikeAnalyticExtractor` | Known focal length, parabolic surface | Parabola shape |
| **OPD (E22)** | `ZernikeOPDExtractor` | **Any surface, most rigorous** | None - uses actual geometry |
**IMPORTANT**: Do NOT provide a figure.dat file unless you're certain it matches your BDF geometry exactly. The default behavior (using BDF geometry filtered to OP2 nodes) is the safest option.
---
## Code Reuse Protocol
@@ -698,7 +857,9 @@ optimization_engine/extractors/
├── extract_mass_from_expression.py # E5
├── field_data_extractor.py # E6
├── stiffness_calculator.py # E7
├── extract_zernike.py # E8, E9
├── extract_zernike.py # E8, E9 (Standard Z-only)
├── extract_zernike_opd.py # E20, E21 (Parabola OPD)
├── extract_zernike_figure.py # E22 (Figure OPD - most rigorous)
├── zernike_helpers.py # E10
├── extract_part_mass_material.py # E11 (Part mass & material)
├── extract_zernike_surface.py # Surface utilities
@@ -728,3 +889,4 @@ nx_journals/
| 1.2 | 2025-12-06 | Added Phase 3: E15-E17 (thermal), E18 (modal mass) |
| 1.3 | 2025-12-07 | Added Element Type Selection Guide; documented shell vs solid stress columns |
| 1.4 | 2025-12-19 | Added Phase 4: E19 (comprehensive part introspection) |
| 1.5 | 2025-12-22 | Added Phase 5: E20 (Parabola OPD), E21 (comparison), E22 (Figure OPD - most rigorous) |