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>
This commit is contained in:
601
optimization_engine/extractors/extract_zernike_surface.py
Normal file
601
optimization_engine/extractors/extract_zernike_surface.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user