| """ |
| STEP 3 — Feature Extraction |
| Extract physics, statistical, and diagnostic features from light curves. |
| """ |
|
|
| from typing import Dict |
| import numpy as np |
| from scipy.stats import skew, kurtosis |
|
|
|
|
| def extract_features( |
| time: np.ndarray, |
| flux: np.ndarray, |
| tls_results: Dict, |
| ) -> Dict: |
| """ |
| Extract features for classification. |
| |
| Args: |
| time: Cleaned time array |
| flux: Cleaned, normalized flux array |
| tls_results: Output from step2_tls.find_period() |
| |
| Returns: |
| Dictionary with keys: |
| - physics: [period, depth, duration, snr, sde] |
| - stats: [skewness, kurtosis, std, mad] |
| - diagnostics: [odd_even_diff, secondary_eclipse_depth] |
| - folded_curve: 2000-point interpolated folded curve |
| """ |
| if not tls_results: |
| return _empty_features() |
|
|
| period = tls_results["period"] |
| if not np.isfinite(period) or period <= 0: |
| return _empty_features() |
|
|
| depth_frac = 1.0 - tls_results["depth"] |
| duration = tls_results["duration"] |
| snr = tls_results["snr"] |
| sde = tls_results["sde"] |
|
|
| |
| physics = np.array([ |
| period, |
| depth_frac, |
| duration, |
| snr, |
| sde, |
| ], dtype=np.float32) |
|
|
| |
| |
| |
| flux_std = np.std(flux) |
| flux_mad = np.median(np.abs(flux - np.median(flux))) |
| flux_skew = skew(flux) |
| flux_kurt = kurtosis(flux) |
|
|
| stats = np.array([ |
| float(flux_skew), |
| float(flux_kurt), |
| float(flux_std), |
| float(flux_mad), |
| ], dtype=np.float32) |
|
|
| |
| folded_time = tls_results["folded_time"] |
| folded_flux = tls_results["folded_flux"] |
|
|
| |
| |
| |
| transit_mask = folded_time > -duration / period / 2 |
| transit_mask &= folded_time < duration / period / 2 |
|
|
| |
| |
| odd_even_diff = _compute_odd_even_diff(time, flux, period, tls_results["t0"]) |
|
|
| |
| secondary_mask = np.abs(folded_time - 0.5) < duration / period / 2 |
| if np.any(secondary_mask): |
| secondary_depth = 1.0 - np.median(folded_flux[secondary_mask]) |
| secondary_depth = max(0.0, float(secondary_depth)) |
| else: |
| secondary_depth = 0.0 |
|
|
| diagnostics = np.array([ |
| float(odd_even_diff), |
| float(secondary_depth), |
| ], dtype=np.float32) |
|
|
| |
| folded_curve = _interpolate_folded_curve( |
| folded_time, folded_flux, n_points=2000 |
| ) |
|
|
| return { |
| "physics": physics, |
| "stats": stats, |
| "diagnostics": diagnostics, |
| "folded_curve": folded_curve, |
| } |
|
|
|
|
| def _compute_odd_even_diff( |
| time: np.ndarray, |
| flux: np.ndarray, |
| period: float, |
| t0: float, |
| ) -> float: |
| """Compute difference between odd and even transit depths.""" |
| |
| transit_numbers = np.floor((time - t0) / period + 0.5).astype(int) |
|
|
| |
| unique_transits = np.unique(transit_numbers) |
| if len(unique_transits) < 2: |
| return 0.0 |
|
|
| odd_depths = [] |
| even_depths = [] |
|
|
| for tn in unique_transits: |
| mask = transit_numbers == tn |
| transit_flux = flux[mask] |
| if len(transit_flux) < 3: |
| continue |
| depth = 1.0 - np.min(transit_flux) |
| if tn % 2 == 0: |
| even_depths.append(depth) |
| else: |
| odd_depths.append(depth) |
|
|
| if not odd_depths or not even_depths: |
| return 0.0 |
|
|
| return float(np.mean(odd_depths) - np.mean(even_depths)) |
|
|
|
|
| def _interpolate_folded_curve( |
| folded_time: np.ndarray, |
| folded_flux: np.ndarray, |
| n_points: int = 2000, |
| ) -> np.ndarray: |
| """Interpolate folded curve to fixed number of points.""" |
| from scipy.interpolate import interp1d |
|
|
| |
| phase_grid = np.linspace(-0.5, 0.5, n_points) |
|
|
| |
| |
| valid = np.isfinite(folded_flux) |
| if np.sum(valid) < 4: |
| return np.ones(n_points, dtype=np.float32) |
|
|
| try: |
| interp = interp1d( |
| folded_time[valid], |
| folded_flux[valid], |
| kind="linear", |
| bounds_error=False, |
| fill_value=1.0, |
| ) |
| curve = interp(phase_grid) |
| except Exception: |
| curve = np.ones(n_points, dtype=np.float32) |
|
|
| return curve.astype(np.float32) |
|
|
|
|
| def _empty_features() -> Dict: |
| """Return empty feature structure for failed detections.""" |
| return { |
| "physics": np.zeros(5, dtype=np.float32), |
| "stats": np.zeros(4, dtype=np.float32), |
| "diagnostics": np.zeros(2, dtype=np.float32), |
| "folded_curve": np.ones(2000, dtype=np.float32), |
| } |
|
|
|
|
| def concatenate_features(features: Dict) -> np.ndarray: |
| """ |
| Concatenate all features into a single vector for the classifier. |
| |
| Returns: |
| 1D array of shape (11 + 2000,) = (2011,) |
| """ |
| return np.concatenate([ |
| features["physics"], |
| features["stats"], |
| features["diagnostics"], |
| features["folded_curve"], |
| ]).astype(np.float32) |