Files
Atomizer/optimization_engine/extractors/extract_zernike_surface.py
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

602 lines
19 KiB
Python

"""
Surface-based Zernike Extraction
================================
This module implements a surface-fitting approach for Zernike extraction that is
robust to mesh regeneration. Instead of relying on specific mesh node IDs (which
change when the mesh regenerates), it:
1. Reads ALL nodes from the OP2/BDF
2. Identifies optical surface nodes by spatial filtering (Z coordinate and radial position)
3. Interpolates displacements onto a fixed regular polar grid
4. Fits Zernike polynomials to the interpolated surface
This approach is stable across mesh changes because:
- The optical surface geometry is defined by physical location, not node IDs
- The interpolation handles different mesh densities consistently
- The fixed evaluation grid ensures comparable results across iterations
"""
import numpy as np
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
from math import factorial
from scipy.interpolate import RBFInterpolator
from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF
# =============================================================================
# Zernike Polynomial Functions (Noll ordering)
# =============================================================================
def noll_indices(j: int) -> Tuple[int, int]:
"""Convert Noll index j to radial order n and azimuthal order m."""
if j < 1:
raise ValueError("Noll index j must be >= 1")
count = 0
n = 0
while True:
if n == 0:
ms = [0]
elif n % 2 == 0:
ms = [0] + [m for k in range(1, n // 2 + 1) for m in (-2 * k, 2 * k)]
else:
ms = [m for k in range(0, (n + 1) // 2) for m in (-(2 * k + 1), (2 * k + 1))]
for m in ms:
count += 1
if count == j:
return n, m
n += 1
def zernike_noll(j: int, r: np.ndarray, th: np.ndarray) -> np.ndarray:
"""
Compute Zernike polynomial for Noll index j.
Args:
j: Noll index (1-based)
r: Normalized radial coordinates (0 to 1)
th: Angular coordinates in radians
Returns:
Zernike polynomial values at each (r, theta) point
"""
n, m = noll_indices(j)
R = np.zeros_like(r)
for s in range((n - abs(m)) // 2 + 1):
c = ((-1) ** s * factorial(n - s) /
(factorial(s) *
factorial((n + abs(m)) // 2 - s) *
factorial((n - abs(m)) // 2 - s)))
R += c * r ** (n - 2 * s)
if m == 0:
return R
return R * (np.cos(m * th) if m > 0 else np.sin(-m * th))
def zernike_mode_name(j: int) -> str:
"""Return descriptive name for Zernike mode."""
names = {
1: "Piston",
2: "Tilt X",
3: "Tilt Y",
4: "Defocus",
5: "Astigmatism 45°",
6: "Astigmatism 0°",
7: "Coma X",
8: "Coma Y",
9: "Trefoil X",
10: "Trefoil Y",
11: "Spherical",
}
return names.get(j, f"Z{j}")
# =============================================================================
# Surface Identification
# =============================================================================
def identify_optical_surface_nodes(
node_coords: Dict[int, np.ndarray],
r_inner: float = 100.0,
r_outer: float = 650.0,
z_tolerance: float = 100.0
) -> List[int]:
"""
Identify nodes on the optical surface by spatial filtering.
The optical surface is identified by:
1. Radial position (between inner and outer radius)
2. Consistent Z range (nodes on the curved mirror surface)
Args:
node_coords: Dictionary mapping node ID to (X, Y, Z) coordinates
r_inner: Inner radius cutoff (central hole)
r_outer: Outer radius limit
z_tolerance: Maximum Z deviation from mean to include
Returns:
List of node IDs on the optical surface
"""
# Get all coordinates as arrays
nids = list(node_coords.keys())
coords = np.array([node_coords[nid] for nid in nids])
# Calculate radial position
r = np.sqrt(coords[:, 0]**2 + coords[:, 1]**2)
# Initial radial filter
radial_mask = (r >= r_inner) & (r <= r_outer)
# Find nodes in radial range
radial_nids = np.array(nids)[radial_mask]
radial_coords = coords[radial_mask]
if len(radial_coords) == 0:
raise ValueError(f"No nodes found in radial range [{r_inner}, {r_outer}]")
# The optical surface should have a relatively small Z range
# Find the mode of Z values (most common Z region)
z_vals = radial_coords[:, 2]
z_mean = np.mean(z_vals)
z_std = np.std(z_vals)
# Filter to nodes within z_tolerance of the mean Z
z_mask = np.abs(radial_coords[:, 2] - z_mean) < z_tolerance
surface_nids = radial_nids[z_mask]
print(f"[SURFACE] Identified {len(surface_nids)} optical surface nodes")
print(f"[SURFACE] Radial range: [{r_inner:.1f}, {r_outer:.1f}] mm")
print(f"[SURFACE] Z range: [{z_vals[z_mask].min():.1f}, {z_vals[z_mask].max():.1f}] mm")
return surface_nids.tolist()
# =============================================================================
# Interpolation to Regular Grid
# =============================================================================
def create_polar_grid(
r_max: float,
r_min: float = 0.0,
n_radial: int = 50,
n_angular: int = 60
) -> Tuple[np.ndarray, np.ndarray]:
"""
Create a regular polar grid for evaluation.
Args:
r_max: Maximum radius
r_min: Minimum radius (central hole)
n_radial: Number of radial points
n_angular: Number of angular points
Returns:
X, Y coordinates of grid points
"""
r = np.linspace(r_min, r_max, n_radial)
theta = np.linspace(0, 2 * np.pi, n_angular, endpoint=False)
R, Theta = np.meshgrid(r, theta)
X = R * np.cos(Theta)
Y = R * np.sin(Theta)
return X.flatten(), Y.flatten()
def interpolate_to_grid(
X_mesh: np.ndarray,
Y_mesh: np.ndarray,
values: np.ndarray,
X_grid: np.ndarray,
Y_grid: np.ndarray,
method: str = 'rbf'
) -> np.ndarray:
"""
Interpolate scattered data to regular grid.
Args:
X_mesh, Y_mesh: Scattered mesh coordinates
values: Values at mesh points
X_grid, Y_grid: Target grid coordinates
method: Interpolation method ('rbf' for radial basis function)
Returns:
Interpolated values at grid points
"""
# Remove NaN values
valid = ~np.isnan(values) & ~np.isnan(X_mesh) & ~np.isnan(Y_mesh)
X_valid = X_mesh[valid]
Y_valid = Y_mesh[valid]
V_valid = values[valid]
if len(V_valid) < 10:
raise ValueError(f"Not enough valid points for interpolation: {len(V_valid)}")
# RBF interpolation (works well for scattered data)
points = np.column_stack([X_valid, Y_valid])
interp = RBFInterpolator(points, V_valid, kernel='thin_plate_spline', smoothing=1e-6)
grid_points = np.column_stack([X_grid, Y_grid])
return interp(grid_points)
# =============================================================================
# Zernike Fitting
# =============================================================================
def fit_zernike_coefficients(
X: np.ndarray,
Y: np.ndarray,
W: np.ndarray,
n_modes: int = 50,
R_max: Optional[float] = None
) -> Tuple[np.ndarray, float]:
"""
Fit Zernike polynomials to surface data.
Args:
X, Y: Coordinates
W: Surface values (WFE in nm)
n_modes: Number of Zernike modes to fit
R_max: Maximum radius for normalization (auto-computed if None)
Returns:
Tuple of (coefficients array, R_max used)
"""
# Center the data
X_c = X - np.mean(X)
Y_c = Y - np.mean(Y)
# Normalize to unit disk
if R_max is None:
R_max = np.max(np.hypot(X_c, Y_c))
r = np.hypot(X_c / R_max, Y_c / R_max)
theta = np.arctan2(Y_c, X_c)
# Mask points inside unit disk with valid values
mask = (r <= 1.0) & ~np.isnan(W)
if not np.any(mask):
raise RuntimeError("No valid points inside unit disk")
r_valid = r[mask]
theta_valid = theta[mask]
W_valid = W[mask]
# Build Zernike basis matrix
Z = np.column_stack([
zernike_noll(j, r_valid, theta_valid).astype(np.float64)
for j in range(1, n_modes + 1)
])
# Least squares fit
try:
coeffs = np.linalg.lstsq(Z, W_valid, rcond=None)[0]
except np.linalg.LinAlgError:
# Fallback to pseudo-inverse
coeffs = np.linalg.pinv(Z) @ W_valid
return coeffs, R_max
def compute_rms_from_surface(
X: np.ndarray,
Y: np.ndarray,
W: np.ndarray,
coeffs: np.ndarray,
R_max: float,
filter_low_orders: int = 4
) -> Tuple[float, float]:
"""
Compute global and filtered RMS from surface data.
Args:
X, Y: Coordinates
W: Surface values (WFE in nm)
coeffs: Zernike coefficients
R_max: Normalization radius
filter_low_orders: Number of low-order modes to filter (typically 4 for piston/tip/tilt/defocus)
Returns:
Tuple of (global_rms, filtered_rms)
"""
# Center and normalize
X_c = X - np.mean(X)
Y_c = Y - np.mean(Y)
r = np.hypot(X_c / R_max, Y_c / R_max)
theta = np.arctan2(Y_c, X_c)
# Mask for valid points
mask = (r <= 1.0) & ~np.isnan(W)
W_valid = W[mask]
# Global RMS
global_rms = np.sqrt(np.mean(W_valid ** 2))
# Build low-order Zernike matrix
Z_low = np.column_stack([
zernike_noll(j, r[mask], theta[mask])
for j in range(1, filter_low_orders + 1)
])
# Reconstruct low-order surface
W_low = Z_low @ coeffs[:filter_low_orders]
# Filtered residual
W_filtered = W_valid - W_low
filtered_rms = np.sqrt(np.mean(W_filtered ** 2))
return global_rms, filtered_rms
# =============================================================================
# Main Extraction Function
# =============================================================================
class SurfaceZernikeExtractor:
"""
Surface-based Zernike extractor that is robust to mesh changes.
"""
def __init__(
self,
r_inner: float = 100.0,
r_outer: float = 600.0,
z_tolerance: float = 100.0,
n_modes: int = 50,
grid_n_radial: int = 50,
grid_n_angular: int = 60
):
"""
Initialize the extractor.
Args:
r_inner: Inner radius cutoff for surface identification
r_outer: Outer radius cutoff
z_tolerance: Z tolerance for surface identification
n_modes: Number of Zernike modes to fit
grid_n_radial: Number of radial points in interpolation grid
grid_n_angular: Number of angular points in interpolation grid
"""
self.r_inner = r_inner
self.r_outer = r_outer
self.z_tolerance = z_tolerance
self.n_modes = n_modes
self.grid_n_radial = grid_n_radial
self.grid_n_angular = grid_n_angular
# Create the evaluation grid once
self.X_grid, self.Y_grid = create_polar_grid(
r_max=r_outer,
r_min=r_inner,
n_radial=grid_n_radial,
n_angular=grid_n_angular
)
def extract_from_op2(
self,
op2_path: Path,
bdf_path: Optional[Path] = None,
reference_subcase: str = "2",
subcases: Optional[List[str]] = None
) -> Dict[str, Dict[str, Any]]:
"""
Extract Zernike coefficients from OP2 file using surface fitting.
Args:
op2_path: Path to OP2 file
bdf_path: Path to BDF/DAT file (auto-detected if None)
reference_subcase: Reference subcase ID for relative calculations
subcases: List of subcase IDs to process (default: ["1", "2", "3", "4"])
Returns:
Dictionary with results for each subcase
"""
op2_path = Path(op2_path)
# Find BDF file
if bdf_path is None:
for ext in ['.dat', '.bdf']:
candidate = op2_path.with_suffix(ext)
if candidate.exists():
bdf_path = candidate
break
if bdf_path is None:
raise FileNotFoundError(f"No .dat or .bdf found for {op2_path}")
if subcases is None:
subcases = ["1", "2", "3", "4"]
print(f"[SURFACE ZERNIKE] Reading geometry from: {bdf_path.name}")
# Read geometry
bdf = BDF()
bdf.read_bdf(str(bdf_path))
node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()}
print(f"[SURFACE ZERNIKE] Total nodes in BDF: {len(node_geo)}")
# Read OP2
print(f"[SURFACE ZERNIKE] Reading displacements from: {op2_path.name}")
op2 = OP2()
op2.read_op2(str(op2_path))
if not op2.displacements:
raise RuntimeError("No displacement data in OP2")
# Extract data for each subcase
subcase_data = {}
NM_PER_MM = 1e6 # mm to nm conversion
for key, darr in op2.displacements.items():
isub = str(getattr(darr, 'isubcase', key))
if isub not in subcases:
continue
data = darr.data
dmat = data[0] if data.ndim == 3 else data
ngt = darr.node_gridtype
node_ids = ngt[:, 0] if ngt.ndim == 2 else ngt
# Get coordinates and Z displacement for each node
X = []
Y = []
disp_z = []
for i, nid in enumerate(node_ids):
if int(nid) in node_geo:
pos = node_geo[int(nid)]
X.append(pos[0])
Y.append(pos[1])
disp_z.append(float(dmat[i, 2]))
X = np.array(X)
Y = np.array(Y)
disp_z = np.array(disp_z)
# Filter to optical surface by radial position
r = np.sqrt(X**2 + Y**2)
surface_mask = (r >= self.r_inner) & (r <= self.r_outer)
subcase_data[isub] = {
'X': X[surface_mask],
'Y': Y[surface_mask],
'disp_z': disp_z[surface_mask],
'node_count': int(np.sum(surface_mask))
}
print(f"[SURFACE ZERNIKE] Subcase {isub}: {subcase_data[isub]['node_count']} surface nodes")
# Process each subcase
results = {}
for isub in subcases:
if isub not in subcase_data:
print(f"[SURFACE ZERNIKE] WARNING: Subcase {isub} not found")
results[isub] = None
continue
data = subcase_data[isub]
X = data['X']
Y = data['Y']
disp_z = data['disp_z']
# Convert to WFE (nm)
wfe_nm = 2.0 * disp_z * NM_PER_MM
# Interpolate to regular grid
try:
wfe_grid = interpolate_to_grid(X, Y, wfe_nm, self.X_grid, self.Y_grid)
except Exception as e:
print(f"[SURFACE ZERNIKE] WARNING: Interpolation failed for subcase {isub}: {e}")
results[isub] = None
continue
# Fit Zernike to interpolated surface
coeffs, R_max = fit_zernike_coefficients(
self.X_grid, self.Y_grid, wfe_grid, self.n_modes
)
# Compute RMS
global_rms, filtered_rms = compute_rms_from_surface(
self.X_grid, self.Y_grid, wfe_grid, coeffs, R_max
)
result = {
'coefficients': coeffs.tolist(),
'global_rms_nm': global_rms,
'filtered_rms_nm': filtered_rms,
'R_max': R_max,
'node_count': data['node_count'],
}
# Compute relative (vs reference) if not the reference
if isub != reference_subcase and reference_subcase in subcase_data:
ref_data = subcase_data[reference_subcase]
# We need to interpolate both to the same grid and subtract
ref_wfe = 2.0 * ref_data['disp_z'] * NM_PER_MM
ref_grid = interpolate_to_grid(
ref_data['X'], ref_data['Y'], ref_wfe, self.X_grid, self.Y_grid
)
# Relative WFE
rel_wfe = wfe_grid - ref_grid
# Fit and compute RMS for relative surface
rel_coeffs, _ = fit_zernike_coefficients(
self.X_grid, self.Y_grid, rel_wfe, self.n_modes, R_max
)
rel_global_rms, rel_filtered_rms = compute_rms_from_surface(
self.X_grid, self.Y_grid, rel_wfe, rel_coeffs, R_max
)
result['relative_coefficients'] = rel_coeffs.tolist()
result['relative_global_rms_nm'] = rel_global_rms
result['relative_filtered_rms_nm'] = rel_filtered_rms
results[isub] = result
return results
# =============================================================================
# Convenience Function
# =============================================================================
def extract_surface_zernike(
op2_path: Path,
bdf_path: Optional[Path] = None,
reference_subcase: str = "2",
r_inner: float = 100.0,
r_outer: float = 600.0
) -> Dict[str, Dict[str, Any]]:
"""
Convenience function to extract Zernike coefficients using surface fitting.
Args:
op2_path: Path to OP2 file
bdf_path: Path to BDF/DAT file (auto-detected if None)
reference_subcase: Reference subcase ID
r_inner: Inner radius for surface identification
r_outer: Outer radius for surface identification
Returns:
Dictionary with results for each subcase
"""
extractor = SurfaceZernikeExtractor(
r_inner=r_inner,
r_outer=r_outer
)
return extractor.extract_from_op2(op2_path, bdf_path, reference_subcase)
if __name__ == '__main__':
# Test the extractor
import sys
if len(sys.argv) < 2:
print("Usage: python extract_zernike_surface.py <op2_path>")
sys.exit(1)
op2_path = Path(sys.argv[1])
results = extract_surface_zernike(op2_path)
print("\n" + "="*60)
print("RESULTS")
print("="*60)
for isub, data in results.items():
if data is None:
print(f"\nSubcase {isub}: No data")
continue
print(f"\nSubcase {isub}:")
print(f" Global RMS: {data['global_rms_nm']:.2f} nm")
print(f" Filtered RMS: {data['filtered_rms_nm']:.2f} nm")
if 'relative_global_rms_nm' in data:
print(f" Relative Global RMS: {data['relative_global_rms_nm']:.2f} nm")
print(f" Relative Filtered RMS: {data['relative_filtered_rms_nm']:.2f} nm")