| |
| |
| """ |
| Robust outlier removal for per-satellite TLE record sequences. |
| |
| Why: raw TLE archives contain corrupted records (bad element fields, wrong |
| epochs, mis-decoded mean anomaly / rev counter). These single-point outliers |
| poison linear interpolation onto the daily grid and the angle unwrapping. The |
| cm-tle-pred benchmark reports that outlier removal (DBSCAN on 1st/2nd-order |
| differences of the elements) gave their single biggest accuracy gain (~2 orders |
| of magnitude). We use a cheaper, dependency-free equivalent: flag any record |
| whose element deviates from a time-linear interpolation of its neighbors by more |
| than ``k_mad`` robust (MAD) scales, on the elements that should evolve smoothly. |
| |
| Cleaning runs on the raw record list BEFORE daily resampling / unwrapping. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import sys |
| from pathlib import Path |
| from typing import List, Tuple |
|
|
| import numpy as np |
|
|
| |
| _CODE_UTILS = Path(__file__).resolve().parents[2] / "code" / "utils" |
| if str(_CODE_UTILS) not in sys.path: |
| sys.path.insert(0, str(_CODE_UTILS)) |
| from tle_future_dataset import TLERecord, signed_log1p |
|
|
| SECONDS_PER_DAY = 86400.0 |
|
|
|
|
| def cumulative_mean_anomaly(recs: List[TLERecord]) -> np.ndarray: |
| """Unwrap mean anomaly into a monotone cumulative phase (deg). |
| |
| Revolution count between epochs is disambiguated with the mean motion |
| (rev/day), which is far more reliable than the TLE rev-counter field. |
| """ |
| n = len(recs) |
| phi = np.empty(n, dtype=np.float64) |
| phi[0] = recs[0].mean_anomaly_deg |
| for i in range(1, n): |
| dt_days = (recs[i].epoch_unix - recs[i - 1].epoch_unix) / SECONDS_PER_DAY |
| n_avg = 0.5 * (recs[i].mean_motion_rev_per_day + recs[i - 1].mean_motion_rev_per_day) |
| predicted = phi[i - 1] + n_avg * dt_days * 360.0 |
| m_i = recs[i].mean_anomaly_deg |
| k = round((predicted - m_i) / 360.0) |
| phi[i] = 360.0 * k + m_i |
| return phi |
|
|
|
|
| def _neighbor_interp_resid(t: np.ndarray, x: np.ndarray) -> np.ndarray: |
| """Residual of each interior point vs a time-linear interp of its neighbors.""" |
| resid = np.zeros_like(x, dtype=np.float64) |
| if len(x) < 3: |
| return resid |
| t0, t1, t2 = t[:-2], t[1:-1], t[2:] |
| denom = np.where((t2 - t0) == 0, 1.0, (t2 - t0)) |
| w = (t1 - t0) / denom |
| x_hat = x[:-2] + (x[2:] - x[:-2]) * w |
| resid[1:-1] = x[1:-1] - x_hat |
| return resid |
|
|
|
|
| def _mad(v: np.ndarray) -> float: |
| v = v[np.isfinite(v)] |
| if v.size == 0: |
| return 0.0 |
| med = np.median(v) |
| return float(1.4826 * np.median(np.abs(v - med))) |
|
|
|
|
| def clean_records( |
| recs: List[TLERecord], k_mad: float = 6.0, max_passes: int = 2, |
| ) -> Tuple[List[TLERecord], int]: |
| """Remove single-point outlier records. Returns (cleaned, n_removed). |
| |
| Flags the union of outliers across the smoothly-evolving quantities: |
| mean motion, inclination, eccentricity, bstar(log), and the cumulative / |
| unwrapped angles (mean anomaly phase, RAAN, argp). |
| """ |
| recs = sorted(recs, key=lambda r: r.epoch_unix) |
| removed = 0 |
| for _ in range(max_passes): |
| n = len(recs) |
| if n < 5: |
| break |
| t = np.array([r.epoch_unix for r in recs], dtype=np.float64) |
| series = { |
| "mm": np.array([r.mean_motion_rev_per_day for r in recs]), |
| "inc": np.array([r.inclination_deg for r in recs]), |
| "ecc": np.array([r.eccentricity for r in recs]), |
| "bstar": np.array([signed_log1p(r.bstar) for r in recs]), |
| "phiM": cumulative_mean_anomaly(recs), |
| "raan": np.degrees(np.unwrap(np.radians([r.raan_deg for r in recs]))), |
| "argp": np.degrees(np.unwrap(np.radians([r.argp_deg for r in recs]))), |
| } |
| flag = np.zeros(n, dtype=bool) |
| for s in series.values(): |
| resid = _neighbor_interp_resid(t, s) |
| scale = _mad(resid[1:-1]) |
| if scale > 0: |
| flag |= np.abs(resid) > (k_mad * scale) |
| flag[0] = flag[-1] = False |
| if not flag.any(): |
| break |
| recs = [r for r, bad in zip(recs, flag) if not bad] |
| removed += int(flag.sum()) |
| return recs, removed |
|
|