"""
Zernike Coefficient Extractor for Telescope Mirror Optimization
================================================================

Extracts Zernike polynomial coefficients from OP2 displacement results
for optical surface quality analysis. Designed for telescope mirror
optimization where wavefront error (WFE) metrics are critical.

Key Features:
- Noll-indexed Zernike polynomials (standard optical convention)
- Multi-subcase support (different gravity orientations: 20, 40, 60, 90 deg)
- Global and filtered RMS wavefront error
- Individual aberration magnitudes (astigmatism, coma, trefoil, spherical)
- Relative metrics between subcases (e.g., operational vs polishing orientation)

Usage:
    from optimization_engine.extractors.extract_zernike import (
        extract_zernike_from_op2,
        ZernikeExtractor
    )

    # Simple usage - get filtered RMS for optimization objective
    result = extract_zernike_from_op2(op2_file, subcase=20)
    rms_filtered = result['filtered_rms_nm']

    # Full extractor for detailed analysis
    extractor = ZernikeExtractor(op2_file, bdf_file)
    metrics = extractor.extract_all_subcases()

Author: Atomizer Framework (adapted from telescope mirror analysis scripts)
"""

from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple, Union
import numpy as np
from math import factorial
from numpy.linalg import LinAlgError

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")


# ============================================================================
# Configuration
# ============================================================================

DEFAULT_N_MODES = 50  # Number of Zernike modes to fit
DEFAULT_FILTER_ORDERS = 4  # Filter first N modes (piston, tip, tilt, defocus)
DEFAULT_CHUNK_SIZE = 100000  # For memory-efficient processing of large meshes

# Standard telescope orientations (gravity angles in degrees)
STANDARD_SUBCASES = [20, 40, 60, 90]

# Displacement unit conversions (to nanometers for WFE)
UNIT_TO_NM = {
    'mm': 1e6,      # 1 mm = 1e6 nm
    'm': 1e9,       # 1 m = 1e9 nm
    'um': 1e3,      # 1 um = 1e3 nm
    'nm': 1.0,      # already nm
}


# ============================================================================
# Zernike Polynomial Mathematics
# ============================================================================

def noll_indices(j: int) -> Tuple[int, int]:
    """
    Convert Noll index j to radial order n and azimuthal frequency m.

    The Noll indexing scheme is the standard convention in optics.
    j=1: Piston, j=2,3: Tip/Tilt, j=4: Defocus, j=5,6: Astigmatism, etc.

    Args:
        j: Noll index (1-based)

    Returns:
        (n, m): Radial order and azimuthal frequency
    """
    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_radial(n: int, m: int, r: np.ndarray) -> np.ndarray:
    """
    Compute the radial component R_n^m(r) of the Zernike polynomial.

    Args:
        n: Radial order
        m: Azimuthal frequency (absolute value used)
        r: Radial coordinates (normalized to unit disk)

    Returns:
        Radial polynomial evaluated at r
    """
    R = np.zeros_like(r)
    m_abs = abs(m)

    for s in range((n - m_abs) // 2 + 1):
        coef = ((-1)**s * factorial(n - s) /
                (factorial(s) *
                 factorial((n + m_abs) // 2 - s) *
                 factorial((n - m_abs) // 2 - s)))
        R += coef * r**(n - 2*s)

    return R


def zernike_noll(j: int, r: np.ndarray, theta: np.ndarray) -> np.ndarray:
    """
    Evaluate Noll-indexed Zernike polynomial Z_j(r, theta).

    Args:
        j: Noll index
        r: Radial coordinates (normalized to unit disk)
        theta: Angular coordinates (radians)

    Returns:
        Zernike polynomial values at (r, theta)
    """
    n, m = noll_indices(j)
    R = zernike_radial(n, m, r)

    if m == 0:
        return R
    elif m > 0:
        return R * np.cos(m * theta)
    else:
        return R * np.sin(-m * theta)


def zernike_name(j: int) -> str:
    """
    Get common optical name for Zernike mode.

    Args:
        j: Noll index

    Returns:
        Human-readable name (e.g., "Defocus", "Astigmatism 0 deg")
    """
    n, m = noll_indices(j)

    names = {
        (0, 0): "Piston",
        (1, -1): "Tilt X",
        (1, 1): "Tilt Y",
        (2, 0): "Defocus",
        (2, -2): "Astigmatism 45 deg",
        (2, 2): "Astigmatism 0 deg",
        (3, -1): "Coma X",
        (3, 1): "Coma Y",
        (3, -3): "Trefoil X",
        (3, 3): "Trefoil Y",
        (4, 0): "Primary Spherical",
        (4, -2): "Secondary Astig X",
        (4, 2): "Secondary Astig Y",
        (4, -4): "Quadrafoil X",
        (4, 4): "Quadrafoil Y",
        (5, -1): "Secondary Coma X",
        (5, 1): "Secondary Coma Y",
        (5, -3): "Secondary Trefoil X",
        (5, 3): "Secondary Trefoil Y",
        (5, -5): "Pentafoil X",
        (5, 5): "Pentafoil Y",
        (6, 0): "Secondary Spherical",
    }

    return names.get((n, m), f"Z(n={n}, m={m})")


def zernike_label(j: int) -> str:
    """Full label for Zernike mode: J{j} - Name (n=, m=)"""
    n, m = noll_indices(j)
    return f"J{j:02d} - {zernike_name(j)} (n={n}, m={m})"


# ============================================================================
# Zernike Coefficient Fitting
# ============================================================================

def compute_zernike_coefficients(
    x: np.ndarray,
    y: np.ndarray,
    values: np.ndarray,
    n_modes: int = DEFAULT_N_MODES,
    chunk_size: int = DEFAULT_CHUNK_SIZE
) -> Tuple[np.ndarray, float]:
    """
    Fit Zernike coefficients to surface data using least-squares.

    Uses chunked processing for memory efficiency with large meshes.
    Points outside the unit disk (after centering/normalization) are excluded.

    Args:
        x, y: Node coordinates (will be centered and normalized)
        values: Surface values at each node (e.g., WFE in nm)
        n_modes: Number of Zernike modes to fit
        chunk_size: Chunk size for memory-efficient processing

    Returns:
        (coefficients, R_max): Zernike coefficients and normalization radius
    """
    # Center coordinates
    x_centered = x - np.mean(x)
    y_centered = y - np.mean(y)

    # Normalize to unit disk
    R_max = float(np.max(np.hypot(x_centered, y_centered)))
    r = np.hypot(x_centered / R_max, y_centered / R_max).astype(np.float32)
    theta = np.arctan2(y_centered, x_centered).astype(np.float32)

    # Mask: inside unit disk and valid values
    mask = (r <= 1.0) & ~np.isnan(values)
    if not np.any(mask):
        raise RuntimeError("No valid points inside unit disk for Zernike fitting.")

    idx = np.nonzero(mask)[0]
    m = int(n_modes)

    # Normal equations: (Z^T Z) c = Z^T v
    # Build incrementally for memory efficiency
    G = np.zeros((m, m), dtype=np.float64)  # Z^T Z
    h = np.zeros((m,), dtype=np.float64)     # Z^T v
    v = values.astype(np.float64)

    for start in range(0, len(idx), chunk_size):
        chunk_idx = idx[start:start + chunk_size]
        r_chunk = r[chunk_idx]
        theta_chunk = theta[chunk_idx]
        v_chunk = v[chunk_idx]

        # Build Zernike basis for this chunk
        Z_chunk = np.column_stack([
            zernike_noll(j, r_chunk, theta_chunk).astype(np.float32)
            for j in range(1, m + 1)
        ])

        # Accumulate normal equations
        G += (Z_chunk.T @ Z_chunk).astype(np.float64)
        h += (Z_chunk.T @ v_chunk).astype(np.float64)

    # Solve normal equations
    try:
        coeffs = np.linalg.solve(G, h)
    except LinAlgError:
        coeffs = np.linalg.lstsq(G, h, rcond=None)[0]

    return coeffs, R_max


# ============================================================================
# RMS Calculations
# ============================================================================

def compute_rms_metrics(
    x: np.ndarray,
    y: np.ndarray,
    wfe: np.ndarray,
    n_modes: int = DEFAULT_N_MODES,
    filter_orders: int = DEFAULT_FILTER_ORDERS
) -> Dict[str, Any]:
    """
    Compute global and filtered RMS wavefront error.

    Args:
        x, y: Node coordinates
        wfe: Wavefront error values (nm)
        n_modes: Number of Zernike modes to fit
        filter_orders: Number of low-order modes to filter (typically 4)

    Returns:
        Dict with 'global_rms_nm', 'filtered_rms_nm', 'rms_filter_j1to3', etc.
    """
    coeffs, R_max = compute_zernike_coefficients(x, y, wfe, n_modes)

    # Reconstruct filtered WFE (remove low-order modes)
    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 Zernike basis for low-order modes only
    Z_low = np.column_stack([
        zernike_noll(j, r, theta) for j in range(1, filter_orders + 1)
    ])

    # Subtract low-order contribution
    wfe_filtered = wfe - Z_low @ coeffs[:filter_orders]

    global_rms = float(np.sqrt(np.mean(wfe**2)))
    filtered_rms = float(np.sqrt(np.mean(wfe_filtered**2)))

    # Also compute J1-J3 filtered RMS (optician workload metric)
    Z_j1to3 = np.column_stack([
        zernike_noll(j, r, theta) for j in range(1, 4)
    ])
    wfe_filter_j1to3 = wfe - Z_j1to3 @ coeffs[:3]
    rms_filter_j1to3 = float(np.sqrt(np.mean(wfe_filter_j1to3**2)))

    return {
        'global_rms_nm': global_rms,
        'filtered_rms_nm': filtered_rms,
        'rms_filter_j1to3': rms_filter_j1to3,
        'coefficients': coeffs,
        'R_max': R_max,
        'X': x,
        'Y': y,
        'wfe': wfe
    }


def compute_aberration_magnitudes(coeffs: np.ndarray) -> Dict[str, float]:
    """
    Compute RMS magnitudes of common optical aberrations.

    Args:
        coeffs: Zernike coefficients (at least 11 modes)

    Returns:
        Dict with aberration RMS values in nm
    """
    if len(coeffs) < 11:
        raise ValueError("Need at least 11 Zernike modes for aberration analysis")

    return {
        'defocus_nm': float(abs(coeffs[3])),  # J4
        'astigmatism_rms_nm': float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)),  # J5+J6
        'coma_rms_nm': float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)),  # J7+J8
        'trefoil_rms_nm': float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)),  # J9+J10
        'spherical_nm': float(abs(coeffs[10])),  # J11
    }


def compute_rms_with_custom_filter(
    x: np.ndarray,
    y: np.ndarray,
    wfe: np.ndarray,
    coeffs: np.ndarray,
    R_max: float,
    filter_modes: int = 3
) -> float:
    """
    Compute RMS with custom number of modes filtered.

    This is useful for specific metrics like optician workload where
    only piston, tip, and tilt (J1-J3) are removed, keeping defocus.

    Args:
        x, y: Node coordinates
        wfe: Wavefront error values (nm)
        coeffs: Pre-computed Zernike coefficients
        R_max: Normalization radius from coefficient fitting
        filter_modes: Number of modes to filter (default 3 for J1-J3)

    Returns:
        RMS after filtering specified modes
    """
    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 Zernike basis for filtered modes
    Z_filter = np.column_stack([
        zernike_noll(j, r, theta) for j in range(1, filter_modes + 1)
    ])

    # Subtract filtered contribution
    wfe_filtered = wfe - Z_filter @ coeffs[:filter_modes]

    return float(np.sqrt(np.mean(wfe_filtered**2)))


# ============================================================================
# OP2/BDF Data Extraction
# ============================================================================

def read_node_geometry(bdf_path: Path) -> Dict[int, np.ndarray]:
    """
    Read node coordinates from BDF/DAT file.

    Args:
        bdf_path: Path to .bdf or .dat file

    Returns:
        Dict mapping node ID to [x, y, z] coordinates
    """
    bdf = BDF()
    bdf.read_bdf(str(bdf_path))

    return {
        int(nid): node.get_position()
        for nid, node in bdf.nodes.items()
    }


def find_geometry_file(op2_path: Path) -> Path:
    """
    Find matching BDF/DAT file for an OP2.

    Looks for same-basename first, then any .dat/.bdf in same folder.

    Args:
        op2_path: Path to OP2 file

    Returns:
        Path to geometry file
    """
    folder = op2_path.parent
    base = op2_path.stem

    # Try same basename
    for ext in ['.dat', '.bdf']:
        cand = folder / (base + ext)
        if cand.exists():
            return cand

    # Try any geometry file
    for name in folder.iterdir():
        if name.suffix.lower() in ['.dat', '.bdf']:
            return name

    raise FileNotFoundError(f"No .dat or .bdf geometry file found for {op2_path}")


def extract_displacements_by_subcase(
    op2_path: Path,
    required_subcases: Optional[List[int]] = None
) -> Dict[str, Dict[str, np.ndarray]]:
    """
    Extract displacement data from OP2 organized by subcase.

    Args:
        op2_path: Path to OP2 file
        required_subcases: List of required subcases (e.g., [20, 40, 60, 90])

    Returns:
        Dict keyed by subcase label: {'20': {'node_ids': array, 'disp': array}, ...}
    """
    op2 = OP2()
    op2.read_op2(str(op2_path))

    if not op2.displacements:
        raise RuntimeError("No displacement data found in OP2 file")

    result = {}

    for key, darr in op2.displacements.items():
        data = darr.data
        dmat = data[0] if data.ndim == 3 else (data if data.ndim == 2 else None)
        if dmat is None:
            continue

        ngt = darr.node_gridtype.astype(int)
        node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]

        # Try to identify subcase from subtitle or isubcase
        subtitle = getattr(darr, 'subtitle', None)
        isubcase = getattr(darr, 'isubcase', None)

        # Extract numeric from subtitle
        label = None
        if isinstance(subtitle, str):
            import re
            m = re.search(r'-?\d+', subtitle)
            if m:
                label = m.group(0)

        if label is None and isinstance(isubcase, int):
            label = str(isubcase)

        if label:
            result[label] = {
                'node_ids': node_ids.astype(int),
                'disp': dmat.copy()
            }

    # Validate required subcases if specified
    if required_subcases:
        missing = [str(s) for s in required_subcases if str(s) not in result]
        if missing:
            available = list(result.keys())
            raise RuntimeError(
                f"Required subcases {missing} not found. Available: {available}"
            )

    return result


# ============================================================================
# Main Extractor Class
# ============================================================================

class ZernikeExtractor:
    """
    Complete Zernike analysis extractor for telescope mirror optimization.

    This class handles:
    - Loading OP2 displacement results
    - Matching with BDF geometry
    - Computing Zernike coefficients and RMS metrics
    - Multi-subcase analysis (different gravity orientations)
    - Relative metrics between subcases

    Example usage in optimization:
        extractor = ZernikeExtractor(op2_file, bdf_file)

        # For single-objective optimization (minimize filtered RMS at 20 deg)
        result = extractor.extract_subcase('20')
        objective = result['filtered_rms_nm']

        # For multi-subcase optimization
        all_results = extractor.extract_all_subcases()
    """

    def __init__(
        self,
        op2_path: Union[str, Path],
        bdf_path: Optional[Union[str, Path]] = None,
        displacement_unit: str = 'mm',
        n_modes: int = DEFAULT_N_MODES,
        filter_orders: int = DEFAULT_FILTER_ORDERS
    ):
        """
        Initialize Zernike extractor.

        Args:
            op2_path: Path to OP2 results file
            bdf_path: Path to BDF/DAT geometry file (auto-detected if None)
            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
        """
        self.op2_path = Path(op2_path)
        self.bdf_path = Path(bdf_path) if bdf_path else find_geometry_file(self.op2_path)
        self.displacement_unit = displacement_unit
        self.n_modes = n_modes
        self.filter_orders = filter_orders

        # Unit conversion factor (displacement to nm)
        self.nm_scale = UNIT_TO_NM.get(displacement_unit.lower(), 1e6)

        # WFE = 2 * surface displacement (optical convention)
        self.wfe_factor = 2.0 * self.nm_scale

        # Lazy-loaded data
        self._node_geo = None
        self._displacements = 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 _build_dataframe(
        self,
        subcase_label: str
    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
        """
        Build coordinate and WFE arrays for a subcase.

        Returns:
            (X, Y, WFE_nm): Arrays of coordinates and wavefront error
        """
        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
        X, Y, WFE = [], [], []
        for nid, vec in zip(node_ids, disp):
            geo = self.node_geometry.get(int(nid))
            if geo is None:
                continue

            X.append(geo[0])
            Y.append(geo[1])
            # Z-displacement to WFE (nm)
            wfe = vec[2] * self.wfe_factor
            WFE.append(wfe)

        return np.array(X), np.array(Y), np.array(WFE)

    def extract_subcase(
        self,
        subcase_label: str,
        include_coefficients: bool = False
    ) -> Dict[str, Any]:
        """
        Extract Zernike metrics for a single subcase.

        Args:
            subcase_label: Subcase identifier (e.g., '20', '90')
            include_coefficients: Whether to include all Zernike coefficients

        Returns:
            Dict with RMS metrics, aberrations, and optionally coefficients
        """
        X, Y, WFE = self._build_dataframe(subcase_label)

        # Compute RMS metrics
        rms_result = compute_rms_metrics(
            X, Y, WFE, self.n_modes, self.filter_orders
        )

        # Compute aberration magnitudes
        aberrations = compute_aberration_magnitudes(rms_result['coefficients'])

        result = {
            'subcase': subcase_label,
            'global_rms_nm': rms_result['global_rms_nm'],
            'filtered_rms_nm': rms_result['filtered_rms_nm'],
            'rms_filter_j1to3': rms_result['rms_filter_j1to3'],
            'n_nodes': len(X),
            **aberrations
        }

        if include_coefficients:
            result['coefficients'] = rms_result['coefficients'].tolist()
            result['coefficient_labels'] = [zernike_label(j) for j in range(1, self.n_modes + 1)]

        return result

    def extract_relative(
        self,
        target_subcase: str,
        reference_subcase: str,
        include_coefficients: bool = False
    ) -> Dict[str, Any]:
        """
        Extract Zernike metrics relative to a reference subcase.

        Computes: WFE_relative = WFE_target - WFE_reference

        Args:
            target_subcase: Subcase to analyze
            reference_subcase: Reference subcase to subtract
            include_coefficients: Whether to include all Zernike coefficients

        Returns:
            Dict with relative RMS metrics and aberrations
        """
        X_t, Y_t, WFE_t = self._build_dataframe(target_subcase)
        X_r, Y_r, WFE_r = self._build_dataframe(reference_subcase)

        # Build node-to-index mapping for reference
        target_data = self.displacements[target_subcase]
        ref_data = self.displacements[reference_subcase]

        ref_node_to_idx = {
            int(nid): i for i, nid in enumerate(ref_data['node_ids'])
        }

        # Compute relative WFE for common nodes
        X_rel, Y_rel, WFE_rel = [], [], []

        for i, nid in enumerate(target_data['node_ids']):
            nid = int(nid)
            if nid not in ref_node_to_idx:
                continue

            ref_idx = ref_node_to_idx[nid]
            geo = self.node_geometry.get(nid)
            if geo is None:
                continue

            X_rel.append(geo[0])
            Y_rel.append(geo[1])

            target_wfe = target_data['disp'][i, 2] * self.wfe_factor
            ref_wfe = ref_data['disp'][ref_idx, 2] * self.wfe_factor
            WFE_rel.append(target_wfe - ref_wfe)

        X_rel = np.array(X_rel)
        Y_rel = np.array(Y_rel)
        WFE_rel = np.array(WFE_rel)

        # Compute metrics on relative WFE
        rms_result = compute_rms_metrics(
            X_rel, Y_rel, WFE_rel, self.n_modes, self.filter_orders
        )
        aberrations = compute_aberration_magnitudes(rms_result['coefficients'])

        result = {
            'target_subcase': target_subcase,
            'reference_subcase': reference_subcase,
            'relative_global_rms_nm': rms_result['global_rms_nm'],
            'relative_filtered_rms_nm': rms_result['filtered_rms_nm'],
            'relative_rms_filter_j1to3': rms_result['rms_filter_j1to3'],
            'n_common_nodes': len(X_rel),
            **{f'relative_{k}': v for k, v in aberrations.items()}
        }

        if include_coefficients:
            result['coefficients'] = rms_result['coefficients'].tolist()
            result['coefficient_labels'] = [zernike_label(j) for j in range(1, self.n_modes + 1)]

        return result

    def extract_all_subcases(
        self,
        reference_subcase: Optional[str] = '20'
    ) -> 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)

            # Add relative metrics if reference specified
            if reference_subcase and label != reference_subcase:
                try:
                    rel = self.extract_relative(label, reference_subcase)
                    results[label].update({
                        f'rel_{reference_subcase}_{k}': v
                        for k, v in rel.items()
                        if k.startswith('relative_')
                    })
                except Exception as e:
                    results[label][f'rel_{reference_subcase}_error'] = str(e)

        return results


# ============================================================================
# Convenience Functions for Optimization
# ============================================================================

def extract_zernike_from_op2(
    op2_file: Union[str, Path],
    bdf_file: Optional[Union[str, Path]] = None,
    subcase: Union[int, str] = 1,
    displacement_unit: str = 'mm',
    n_modes: int = DEFAULT_N_MODES,
    filter_orders: int = DEFAULT_FILTER_ORDERS
) -> Dict[str, Any]:
    """
    Convenience function to extract Zernike metrics from OP2.

    This is the main entry point for optimization objectives.

    Args:
        op2_file: Path to OP2 results file
        bdf_file: Path to BDF geometry (auto-detected if None)
        subcase: Subcase identifier
        displacement_unit: Unit of displacement in OP2
        n_modes: Number of Zernike modes
        filter_orders: Low-order modes to filter

    Returns:
        Dict with:
        - 'global_rms_nm': Global RMS WFE in nanometers
        - 'filtered_rms_nm': Filtered RMS (low orders removed)
        - 'defocus_nm', 'astigmatism_rms_nm', etc.: Individual aberrations
    """
    extractor = ZernikeExtractor(
        op2_file, bdf_file, displacement_unit, n_modes, filter_orders
    )
    return extractor.extract_subcase(str(subcase))


def extract_zernike_filtered_rms(
    op2_file: Union[str, Path],
    bdf_file: Optional[Union[str, Path]] = None,
    subcase: Union[int, str] = 1,
    **kwargs
) -> float:
    """
    Extract filtered RMS WFE - the primary metric for mirror optimization.

    Filtered RMS removes piston, tip, tilt, and defocus (modes 1-4),
    which can be corrected by alignment and focus adjustment.

    Args:
        op2_file: Path to OP2 file
        bdf_file: Path to BDF geometry (auto-detected if None)
        subcase: Subcase identifier
        **kwargs: Additional arguments for ZernikeExtractor

    Returns:
        Filtered RMS WFE in nanometers
    """
    result = extract_zernike_from_op2(op2_file, bdf_file, subcase, **kwargs)
    return result['filtered_rms_nm']


def extract_zernike_relative_rms(
    op2_file: Union[str, Path],
    target_subcase: Union[int, str],
    reference_subcase: Union[int, str],
    bdf_file: Optional[Union[str, Path]] = None,
    **kwargs
) -> float:
    """
    Extract relative filtered RMS between two subcases.

    Useful for analyzing gravity-induced deformation relative to
    a reference orientation (e.g., polishing position).

    Args:
        op2_file: Path to OP2 file
        target_subcase: Subcase to analyze
        reference_subcase: Reference subcase
        bdf_file: Path to BDF geometry
        **kwargs: Additional arguments for ZernikeExtractor

    Returns:
        Relative filtered RMS WFE in nanometers
    """
    extractor = ZernikeExtractor(op2_file, bdf_file, **kwargs)
    result = extractor.extract_relative(str(target_subcase), str(reference_subcase))
    return result['relative_filtered_rms_nm']


# ============================================================================
# Module Exports
# ============================================================================

__all__ = [
    # Main extractor class
    'ZernikeExtractor',

    # Convenience functions for optimization
    'extract_zernike_from_op2',
    'extract_zernike_filtered_rms',
    'extract_zernike_relative_rms',

    # Zernike utilities (for advanced use)
    'compute_zernike_coefficients',
    'compute_rms_metrics',
    'compute_rms_with_custom_filter',
    'compute_aberration_magnitudes',
    'noll_indices',
    'zernike_noll',
    'zernike_name',
    'zernike_label',
]


if __name__ == '__main__':
    # Example/test usage
    import sys

    if len(sys.argv) > 1:
        op2_file = Path(sys.argv[1])

        print(f"Analyzing: {op2_file}")

        try:
            extractor = ZernikeExtractor(op2_file)

            print(f"\nAvailable subcases: {list(extractor.displacements.keys())}")

            results = extractor.extract_all_subcases()

            for label, metrics in results.items():
                print(f"\n=== Subcase {label} ===")
                print(f"  Global RMS:   {metrics['global_rms_nm']:.2f} nm")
                print(f"  Filtered RMS: {metrics['filtered_rms_nm']:.2f} nm")
                print(f"  Astigmatism:  {metrics['astigmatism_rms_nm']:.2f} nm")
                print(f"  Coma:         {metrics['coma_rms_nm']:.2f} nm")
                print(f"  Trefoil:      {metrics['trefoil_rms_nm']:.2f} nm")
                print(f"  Spherical:    {metrics['spherical_nm']:.2f} nm")

        except Exception as e:
            print(f"Error: {e}")
            sys.exit(1)
    else:
        print("Usage: python extract_zernike.py <op2_file>")
        print("\nThis module provides Zernike coefficient extraction for telescope mirror optimization.")
        print("\nExample in optimization config:")
        print('  "objectives": [')
        print('    {')
        print('      "name": "filtered_rms",')
        print('      "extractor": "zernike",')
        print('      "direction": "minimize",')
        print('      "extractor_config": {')
        print('        "subcase": "20",')
        print('        "metric": "filtered_rms_nm"')
        print('      }')
        print('    }')
        print('  ]')
