""" Analytic (Parabola-Based) Zernike Extractor for Telescope Mirror Optimization ============================================================================== This extractor computes OPD using an ANALYTICAL parabola formula, accounting for lateral (X, Y) displacements in addition to axial (Z) displacements. NOTE: For most robust OPD analysis, prefer ZernikeOPDExtractor from extract_zernike_figure.py which uses actual mesh geometry as reference (no shape assumptions). This analytic extractor is useful when: - You know the optical prescription (focal length) - The surface is a parabola (or close to it) - You want to compare against theoretical parabola WHY LATERAL CORRECTION MATTERS: ------------------------------- The standard Zernike approach uses original (x, y) coordinates with only Z-displacement. This is INCORRECT when lateral displacements are significant because: 1. A node at (x₀, y₀) moves to (x₀+Δx, y₀+Δy, z₀+Δz) 2. The parabola surface at the NEW position has a different expected Z 3. The true surface error is: (z₀+Δz) - z_parabola(x₀+Δx, y₀+Δy) PARABOLA EQUATION: ------------------ For a paraboloid with vertex at origin and optical axis along Z: z = (x² + y²) / (4 * f) where f = focal length = R / 2 (R = radius of curvature at vertex) For a concave mirror (like telescope primary): z = -r² / (4 * f) (negative because surface curves away from +Z) We support both conventions via the `concave` parameter. Author: Atomizer Framework Date: 2024 """ from pathlib import Path from typing import Dict, Any, Optional, List, Tuple, Union import numpy as np from numpy.linalg import LinAlgError # Import base Zernike functionality from existing module from .extract_zernike import ( compute_zernike_coefficients, compute_aberration_magnitudes, zernike_noll, zernike_label, read_node_geometry, find_geometry_file, extract_displacements_by_subcase, UNIT_TO_NM, DEFAULT_N_MODES, DEFAULT_FILTER_ORDERS, ) try: from pyNastran.op2.op2 import OP2 from pyNastran.bdf.bdf import BDF except ImportError: raise ImportError("pyNastran is required. Install with: pip install pyNastran") # ============================================================================ # OPD Calculation Functions # ============================================================================ def compute_parabola_z( x: np.ndarray, y: np.ndarray, focal_length: float, concave: bool = True ) -> np.ndarray: """ Compute the Z coordinate on a paraboloid surface. For a paraboloid: z = ±(x² + y²) / (4 * f) Args: x, y: Coordinates on the surface focal_length: Focal length of the parabola (f = R_vertex / 2) concave: If True, mirror curves toward -Z (typical telescope primary) Returns: Z coordinates on the parabola surface """ r_squared = x**2 + y**2 z = r_squared / (4.0 * focal_length) if concave: z = -z # Concave mirror curves toward -Z return z def compute_true_opd( x0: np.ndarray, y0: np.ndarray, z0: np.ndarray, dx: np.ndarray, dy: np.ndarray, dz: np.ndarray, focal_length: float, concave: bool = True ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ Compute true Optical Path Difference accounting for lateral displacement. The key insight: when a node moves laterally, we must compare its new Z position against what the parabola Z SHOULD BE at that new (x, y) location. The CORRECT formulation: - Original surface: z0 = parabola(x0, y0) + manufacturing_errors - Deformed surface: z0 + dz = parabola(x0 + dx, y0 + dy) + total_errors The surface error we want is: error = (z0 + dz) - parabola(x0 + dx, y0 + dy) But since z0 ≈ parabola(x0, y0), we can compute the DIFFERENTIAL: error ≈ dz - [parabola(x0 + dx, y0 + dy) - parabola(x0, y0)] error ≈ dz - Δz_parabola where Δz_parabola is the change in ideal parabola Z due to lateral movement. Args: x0, y0, z0: Original node coordinates (undeformed) dx, dy, dz: Displacement components from FEA focal_length: Parabola focal length concave: Whether mirror is concave (typical) Returns: Tuple of: - x_def: Deformed X coordinates (for Zernike fitting) - y_def: Deformed Y coordinates (for Zernike fitting) - surface_error: True surface error at deformed positions - lateral_magnitude: Magnitude of lateral displacement (diagnostic) """ # Deformed coordinates x_def = x0 + dx y_def = y0 + dy # Compute the CHANGE in parabola Z due to lateral displacement # For parabola: z = ±r²/(4f), so: # Δz_parabola = z(x_def, y_def) - z(x0, y0) # = [(x0+dx)² + (y0+dy)² - x0² - y0²] / (4f) # = [2*x0*dx + dx² + 2*y0*dy + dy²] / (4f) # Original r² and deformed r² r0_sq = x0**2 + y0**2 r_def_sq = x_def**2 + y_def**2 # Change in r² due to lateral displacement delta_r_sq = r_def_sq - r0_sq # Change in parabola Z (the Z shift that would keep the node on the ideal parabola) delta_z_parabola = delta_r_sq / (4.0 * focal_length) if concave: delta_z_parabola = -delta_z_parabola # TRUE surface error = actual dz - expected dz (to stay on parabola) # If node moves laterally outward (larger r), parabola Z changes # The error is the difference between actual Z movement and expected Z movement surface_error = dz - delta_z_parabola # Diagnostic: lateral displacement magnitude lateral_magnitude = np.sqrt(dx**2 + dy**2) return x_def, y_def, surface_error, lateral_magnitude def estimate_focal_length_from_geometry( x: np.ndarray, y: np.ndarray, z: np.ndarray, concave: bool = True ) -> float: """ Estimate focal length from node geometry by fitting a paraboloid. Uses least-squares fit: z = a * (x² + y²) + b where focal_length = 1 / (4 * |a|) Args: x, y, z: Node coordinates concave: Whether mirror is concave Returns: Estimated focal length """ r_squared = x**2 + y**2 # Fit z = a * r² + b A = np.column_stack([r_squared, np.ones_like(r_squared)]) try: coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None) a = coeffs[0] except LinAlgError: # Fallback: use simple ratio mask = r_squared > 0 a = np.median(z[mask] / r_squared[mask]) # For concave mirror, a should be negative # focal_length = 1 / (4 * |a|) if abs(a) < 1e-12: raise ValueError("Cannot estimate focal length - surface appears flat") focal_length = 1.0 / (4.0 * abs(a)) return focal_length # ============================================================================ # Main Analytic (Parabola-Based) Extractor Class # ============================================================================ class ZernikeAnalyticExtractor: """ Analytic (parabola-based) Zernike extractor for telescope mirror optimization. This extractor accounts for lateral (X, Y) displacements when computing wavefront error, using an analytical parabola formula as reference. NOTE: For most robust analysis, prefer ZernikeOPDExtractor from extract_zernike_figure.py which uses actual mesh geometry (no shape assumptions). Use this extractor when: - You know the focal length / optical prescription - The surface is parabolic (or near-parabolic) - You want to compare against theoretical parabola shape Example: extractor = ZernikeAnalyticExtractor( op2_file, bdf_file, focal_length=5000.0 # mm ) result = extractor.extract_subcase('20') # Metrics available: print(f"Max lateral displacement: {result['max_lateral_disp_um']:.2f} µm") print(f"RMS lateral displacement: {result['rms_lateral_disp_um']:.2f} µm") """ def __init__( self, op2_path: Union[str, Path], bdf_path: Optional[Union[str, Path]] = None, focal_length: Optional[float] = None, concave: bool = True, displacement_unit: str = 'mm', n_modes: int = DEFAULT_N_MODES, filter_orders: int = DEFAULT_FILTER_ORDERS, auto_estimate_focal: bool = True ): """ Initialize OPD-based Zernike extractor. Args: op2_path: Path to OP2 results file bdf_path: Path to BDF/DAT geometry file (auto-detected if None) focal_length: Parabola focal length in same units as geometry If None and auto_estimate_focal=True, will estimate from mesh concave: Whether mirror is concave (curves toward -Z) displacement_unit: Unit of displacement in OP2 ('mm', 'm', 'um', 'nm') n_modes: Number of Zernike modes to fit filter_orders: Number of low-order modes to filter auto_estimate_focal: If True, estimate focal length from geometry """ self.op2_path = Path(op2_path) self.bdf_path = Path(bdf_path) if bdf_path else find_geometry_file(self.op2_path) self.focal_length = focal_length self.concave = concave self.displacement_unit = displacement_unit self.n_modes = n_modes self.filter_orders = filter_orders self.auto_estimate_focal = auto_estimate_focal # Unit conversion factor self.nm_scale = UNIT_TO_NM.get(displacement_unit.lower(), 1e6) self.um_scale = self.nm_scale / 1000.0 # For lateral displacement reporting # WFE = 2 * surface error (optical convention for reflection) self.wfe_factor = 2.0 * self.nm_scale # Lazy-loaded data self._node_geo = None self._displacements = None self._estimated_focal = None @property def node_geometry(self) -> Dict[int, np.ndarray]: """Lazy-load node geometry from BDF.""" if self._node_geo is None: self._node_geo = read_node_geometry(self.bdf_path) return self._node_geo @property def displacements(self) -> Dict[str, Dict[str, np.ndarray]]: """Lazy-load displacements from OP2.""" if self._displacements is None: self._displacements = extract_displacements_by_subcase(self.op2_path) return self._displacements def get_focal_length(self, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> float: """ Get focal length, estimating from geometry if not provided. """ if self.focal_length is not None: return self.focal_length if self._estimated_focal is not None: return self._estimated_focal if self.auto_estimate_focal: self._estimated_focal = estimate_focal_length_from_geometry( x, y, z, self.concave ) return self._estimated_focal raise ValueError( "focal_length must be provided or auto_estimate_focal must be True" ) def _build_opd_data( self, subcase_label: str ) -> Dict[str, np.ndarray]: """ Build OPD data arrays for a subcase using rigorous method. Returns: Dict with: - x_original, y_original, z_original: Original coordinates - dx, dy, dz: Displacement components - x_deformed, y_deformed: Deformed coordinates (for Zernike fitting) - surface_error: True surface error (mm or model units) - wfe_nm: Wavefront error in nanometers - lateral_disp: Lateral displacement magnitude """ if subcase_label not in self.displacements: available = list(self.displacements.keys()) raise ValueError(f"Subcase '{subcase_label}' not found. Available: {available}") data = self.displacements[subcase_label] node_ids = data['node_ids'] disp = data['disp'] # Build arrays x0, y0, z0 = [], [], [] dx_arr, dy_arr, dz_arr = [], [], [] for nid, vec in zip(node_ids, disp): geo = self.node_geometry.get(int(nid)) if geo is None: continue x0.append(geo[0]) y0.append(geo[1]) z0.append(geo[2]) dx_arr.append(vec[0]) dy_arr.append(vec[1]) dz_arr.append(vec[2]) x0 = np.array(x0) y0 = np.array(y0) z0 = np.array(z0) dx_arr = np.array(dx_arr) dy_arr = np.array(dy_arr) dz_arr = np.array(dz_arr) # Get focal length focal = self.get_focal_length(x0, y0, z0) # Compute true OPD x_def, y_def, surface_error, lateral_disp = compute_true_opd( x0, y0, z0, dx_arr, dy_arr, dz_arr, focal, self.concave ) # Convert to WFE (nm) wfe_nm = surface_error * self.wfe_factor return { 'x_original': x0, 'y_original': y0, 'z_original': z0, 'dx': dx_arr, 'dy': dy_arr, 'dz': dz_arr, 'x_deformed': x_def, 'y_deformed': y_def, 'surface_error': surface_error, 'wfe_nm': wfe_nm, 'lateral_disp': lateral_disp, 'focal_length': focal } def extract_subcase( self, subcase_label: str, include_coefficients: bool = False, include_diagnostics: bool = True ) -> Dict[str, Any]: """ Extract Zernike metrics for a single subcase using rigorous OPD method. Args: subcase_label: Subcase identifier (e.g., '20', '90') include_coefficients: Whether to include all Zernike coefficients include_diagnostics: Whether to include lateral displacement diagnostics Returns: Dict with RMS metrics, aberrations, and lateral displacement info """ opd_data = self._build_opd_data(subcase_label) # Use DEFORMED coordinates for Zernike fitting X = opd_data['x_deformed'] Y = opd_data['y_deformed'] WFE = opd_data['wfe_nm'] # Fit Zernike coefficients coeffs, R_max = compute_zernike_coefficients(X, Y, WFE, self.n_modes) # Compute RMS metrics # Center on deformed coordinates 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) # Build low-order Zernike basis Z_low = np.column_stack([ zernike_noll(j, r, theta) for j in range(1, self.filter_orders + 1) ]) # Filtered WFE wfe_filtered = WFE - Z_low @ coeffs[:self.filter_orders] global_rms = float(np.sqrt(np.mean(WFE**2))) filtered_rms = float(np.sqrt(np.mean(wfe_filtered**2))) # J1-J3 filtered (optician workload) Z_j1to3 = np.column_stack([ zernike_noll(j, r, theta) for j in range(1, 4) ]) wfe_j1to3 = WFE - Z_j1to3 @ coeffs[:3] rms_j1to3 = float(np.sqrt(np.mean(wfe_j1to3**2))) # Aberration magnitudes aberrations = compute_aberration_magnitudes(coeffs) result = { 'subcase': subcase_label, 'method': 'opd_rigorous', 'global_rms_nm': global_rms, 'filtered_rms_nm': filtered_rms, 'rms_filter_j1to3_nm': rms_j1to3, 'n_nodes': len(X), 'focal_length_used': opd_data['focal_length'], **aberrations } if include_diagnostics: lateral = opd_data['lateral_disp'] result.update({ 'max_lateral_disp_um': float(np.max(lateral) * self.um_scale), 'rms_lateral_disp_um': float(np.sqrt(np.mean(lateral**2)) * self.um_scale), 'mean_lateral_disp_um': float(np.mean(lateral) * self.um_scale), 'p99_lateral_disp_um': float(np.percentile(lateral, 99) * self.um_scale), }) if include_coefficients: result['coefficients'] = coeffs.tolist() result['coefficient_labels'] = [ zernike_label(j) for j in range(1, self.n_modes + 1) ] return result def extract_comparison( self, subcase_label: str ) -> Dict[str, Any]: """ Extract metrics using BOTH methods for comparison. Useful for understanding the impact of the rigorous method vs the standard Z-only approach. Returns: Dict with metrics from both methods and the delta """ from .extract_zernike import ZernikeExtractor # Rigorous OPD method opd_result = self.extract_subcase(subcase_label, include_diagnostics=True) # Standard Z-only method standard_extractor = ZernikeExtractor( self.op2_path, self.bdf_path, self.displacement_unit, self.n_modes, self.filter_orders ) standard_result = standard_extractor.extract_subcase(subcase_label) # Compute deltas delta_global = opd_result['global_rms_nm'] - standard_result['global_rms_nm'] delta_filtered = opd_result['filtered_rms_nm'] - standard_result['filtered_rms_nm'] return { 'subcase': subcase_label, 'opd_method': { 'global_rms_nm': opd_result['global_rms_nm'], 'filtered_rms_nm': opd_result['filtered_rms_nm'], }, 'standard_method': { 'global_rms_nm': standard_result['global_rms_nm'], 'filtered_rms_nm': standard_result['filtered_rms_nm'], }, 'delta': { 'global_rms_nm': delta_global, 'filtered_rms_nm': delta_filtered, 'percent_difference_filtered': 100.0 * delta_filtered / standard_result['filtered_rms_nm'] if standard_result['filtered_rms_nm'] > 0 else 0.0, }, 'lateral_displacement': { 'max_um': opd_result['max_lateral_disp_um'], 'rms_um': opd_result['rms_lateral_disp_um'], 'p99_um': opd_result['p99_lateral_disp_um'], }, 'recommendation': _get_method_recommendation(opd_result) } def extract_all_subcases( self, reference_subcase: Optional[str] = None ) -> Dict[str, Dict[str, Any]]: """ Extract metrics for all available subcases. Args: reference_subcase: Reference for relative calculations (None to skip) Returns: Dict mapping subcase label to metrics dict """ results = {} for label in self.displacements.keys(): results[label] = self.extract_subcase(label) return results def get_diagnostic_data( self, subcase_label: str ) -> Dict[str, np.ndarray]: """ Get raw data for visualization/debugging. Returns arrays suitable for plotting lateral displacement maps, comparing methods, etc. """ return self._build_opd_data(subcase_label) def _get_method_recommendation(opd_result: Dict[str, Any]) -> str: """ Generate recommendation based on lateral displacement magnitude. """ max_lateral = opd_result.get('max_lateral_disp_um', 0) rms_lateral = opd_result.get('rms_lateral_disp_um', 0) if max_lateral > 10.0: # > 10 µm max lateral return "CRITICAL: Large lateral displacements detected. OPD method strongly recommended." elif max_lateral > 1.0: # > 1 µm return "RECOMMENDED: Significant lateral displacements. OPD method provides more accurate results." elif max_lateral > 0.1: # > 0.1 µm return "OPTIONAL: Small lateral displacements. OPD method provides minor improvement." else: return "EQUIVALENT: Negligible lateral displacements. Both methods give similar results." # ============================================================================ # Convenience Functions # ============================================================================ def extract_zernike_analytic( op2_file: Union[str, Path], bdf_file: Optional[Union[str, Path]] = None, subcase: Union[int, str] = 1, focal_length: Optional[float] = None, displacement_unit: str = 'mm', **kwargs ) -> Dict[str, Any]: """ Convenience function to extract Zernike metrics using analytic parabola method. NOTE: For most robust analysis, prefer extract_zernike_opd() from extract_zernike_figure.py which uses actual mesh geometry. Args: op2_file: Path to OP2 results file bdf_file: Path to BDF geometry (auto-detected if None) subcase: Subcase identifier focal_length: Parabola focal length (auto-estimated if None) displacement_unit: Unit of displacement in OP2 **kwargs: Additional arguments for ZernikeAnalyticExtractor Returns: Dict with RMS metrics, aberrations, and lateral displacement info """ extractor = ZernikeAnalyticExtractor( op2_file, bdf_file, focal_length=focal_length, displacement_unit=displacement_unit, **kwargs ) return extractor.extract_subcase(str(subcase)) def extract_zernike_analytic_filtered_rms( op2_file: Union[str, Path], subcase: Union[int, str] = 1, **kwargs ) -> float: """ Extract filtered RMS WFE using analytic parabola method. NOTE: For most robust analysis, prefer extract_zernike_opd_filtered_rms() from extract_zernike_figure.py. """ result = extract_zernike_analytic(op2_file, subcase=subcase, **kwargs) return result['filtered_rms_nm'] def compare_zernike_methods( op2_file: Union[str, Path], bdf_file: Optional[Union[str, Path]] = None, subcase: Union[int, str] = 1, focal_length: Optional[float] = None, **kwargs ) -> Dict[str, Any]: """ Compare standard vs analytic (parabola) Zernike methods. Use this to understand how much lateral displacement affects your results. """ extractor = ZernikeAnalyticExtractor( op2_file, bdf_file, focal_length=focal_length, **kwargs ) return extractor.extract_comparison(str(subcase)) # Backwards compatibility aliases ZernikeOPDExtractor = ZernikeAnalyticExtractor # Deprecated: use ZernikeAnalyticExtractor extract_zernike_opd = extract_zernike_analytic # Deprecated: use extract_zernike_analytic extract_zernike_opd_filtered_rms = extract_zernike_analytic_filtered_rms # Deprecated # ============================================================================ # Module Exports # ============================================================================ __all__ = [ # Main extractor class (new name) 'ZernikeAnalyticExtractor', # Convenience functions (new names) 'extract_zernike_analytic', 'extract_zernike_analytic_filtered_rms', 'compare_zernike_methods', # Utility functions 'compute_true_opd', 'compute_parabola_z', 'estimate_focal_length_from_geometry', # Backwards compatibility (deprecated) 'ZernikeOPDExtractor', 'extract_zernike_opd', 'extract_zernike_opd_filtered_rms', ] if __name__ == '__main__': import sys if len(sys.argv) > 1: op2_file = Path(sys.argv[1]) focal = float(sys.argv[2]) if len(sys.argv) > 2 else None print(f"Analyzing: {op2_file}") print(f"Focal length: {focal if focal else 'auto-estimate'}") print("=" * 60) try: extractor = ZernikeOPDExtractor(op2_file, focal_length=focal) print(f"\nAvailable subcases: {list(extractor.displacements.keys())}") for label in extractor.displacements.keys(): print(f"\n{'=' * 60}") print(f"Subcase {label}") print('=' * 60) # Compare methods comparison = extractor.extract_comparison(label) print(f"\nStandard (Z-only) method:") print(f" Global RMS: {comparison['standard_method']['global_rms_nm']:.2f} nm") print(f" Filtered RMS: {comparison['standard_method']['filtered_rms_nm']:.2f} nm") print(f"\nRigorous OPD method:") print(f" Global RMS: {comparison['opd_method']['global_rms_nm']:.2f} nm") print(f" Filtered RMS: {comparison['opd_method']['filtered_rms_nm']:.2f} nm") print(f"\nDifference (OPD - Standard):") print(f" Filtered RMS: {comparison['delta']['filtered_rms_nm']:+.2f} nm ({comparison['delta']['percent_difference_filtered']:+.1f}%)") print(f"\nLateral Displacement:") print(f" Max: {comparison['lateral_displacement']['max_um']:.3f} µm") print(f" RMS: {comparison['lateral_displacement']['rms_um']:.3f} µm") print(f" P99: {comparison['lateral_displacement']['p99_um']:.3f} µm") print(f"\n→ {comparison['recommendation']}") except Exception as e: print(f"Error: {e}") import traceback traceback.print_exc() sys.exit(1) else: print("OPD-Based Zernike Extractor") print("=" * 40) print("\nUsage: python extract_zernike_opd.py [focal_length]") print("\nThis module provides rigorous OPD-based Zernike extraction") print("that accounts for lateral (X, Y) displacements.") print("\nKey features:") print(" - True optical path difference calculation") print(" - Accounts for node lateral shift due to pinching/clamping") print(" - Lateral displacement diagnostics") print(" - Method comparison (standard vs OPD)")