Leonardo0711/exoplanet-api
0
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3"""4predict_part.py — Predicción por PARTES (sin stitch) usando el modelo "parts"5 6• Usa por defecto:7 models/baseline_parts.pkl8 models/baseline_parts_thresholds.json9 10• Flujo: (opcional) curate → features por PARTE → predicción → (opcional) agregado por target.11• Metadata por PARTE (segment/author) opcional, igual que en el training:12 columnas esperadas: mission, label, target_id, segment, author,13 stellar_radius_rsun, stellar_mass_msun, stellar_teff_k, stellar_logg_cgs, period_days14 15Novedades:16 - Genera artefactos/figuras por PARTE en out/artifacts_parts/<key>/:17 * lc_raw.png (curva normalizada)18 * lc_dtr.png (curva aplanada)19 * periodogram.png (BLS agregado)20 * phase.png (fase con binning)21 * odd_even.png (puntos impares/pares en ventana)22 * marks.png (ventanas de tránsito sobre la serie)23 * manifest.json (metadatos de la parte)24 - Si metadata trae period_days ∈ [0.5, 30], BLS se restringe a [0.9P, 1.1P].25 - Preprocesamiento igual al training (filtro de calidad, sigma-clip, flatten, pmin ajustado por span).26 - Orden de features desde baseline_parts_thresholds.json ("feature_names").27 28Ejemplos:29 30# A) ***Una sola parte*** (ej. KIC 4680772, part003)31python predict_part.py --raw-root "prueba/raw" --out "prueba/out_4680772_part003" --only-target 'KIC[_ ]4680772.*part003' --metadata "prueba/metadata.part.csv"32 33# B) ***Todas las partes*** de un target (ej. KIC 4680772)34python predict_part.py --raw-root "prueba/raw" --out "prueba/out_4680772_allparts" --only-target "4680772" --metadata "prueba/metadata.part.csv"35 36# C) ***Varias partes específicas*** (ej. 003, 004 y 007)37python predict_part.py --raw-root "prueba/raw" --out "prueba/out_4680772_part_003_004_007" --only-target 'KIC[_ ]4680772.*part(003|004|007)' --metadata "prueba/metadata.part.csv"38 39# D) (Opcional) Agregar por target con MAX cuando corriste varias partes40python predict_part.py --raw-root "prueba/raw" --out "prueba/out_4680772_allparts" --only-target "4680772" --aggregate-by-target max --metadata "prueba/metadata.part.csv"41"""42 43from __future__ import annotations44import argparse, json, math, re, sys, warnings45from dataclasses import dataclass, asdict46from concurrent.futures import ThreadPoolExecutor, as_completed47from pathlib import Path48from typing import Optional, Dict, Any, List, Tuple49 50import numpy as np51import pandas as pd52from tqdm import tqdm53from astropy.timeseries import BoxLeastSquares54import joblib55 56# Matplotlib (modo headless para servidores/CI)57import matplotlib58matplotlib.use("Agg")59import matplotlib.pyplot as plt60 61# ========================= CONFIG / DEFAULTS =========================62MODELS_DIR = Path("models")63DEFAULT_PKL = MODELS_DIR / "baseline_parts.pkl"64DEFAULT_THR = MODELS_DIR / "baseline_parts_thresholds.json"65 66P_MIN, P_MAX = 0.5, 30.0 # días (rango BLS)67DUR_FRACS = np.array([0.008, 0.015, 0.03, 0.05], dtype=float)68ROLL_WINDOW = 40169BINS = 20070EPS = 1e-971 72QUALITY_CANDS = ["quality","quality_flags","dq","flag","flags"]73 74# ========================= NORMALIZADORES (IDs/segment/author) =========================75_ID_CANON_RE = re.compile(r'(?i)\b(KIC|TIC)\b[ _-]?(\d+)')76 77def canonical_tid(s: Optional[str], prefer_prefix: Optional[str] = None) -> str:78 if not s:79 return ""80 s2 = str(s).strip()81 m = _ID_CANON_RE.search(s2)82 if m:83 return f"{m.group(1).upper()} {int(m.group(2))}"84 if s2.isdigit() and prefer_prefix:85 return f"{prefer_prefix.upper()} {int(s2)}"86 s2 = s2.replace("_", " ").replace("-", " ")87 s2 = re.sub(r"\s+", " ", s2).strip()88 return s2.upper()89 90def canonical_segment(s: Optional[str]) -> str:91 if not s or str(s).strip() == "":92 return ""93 ss = str(s).strip().upper().replace("SEG", "").strip()94 m = re.match(r'^(Q|S|C)\s*0*?(\d+)$', ss)95 if m:96 tag = m.group(1)97 num = int(m.group(2))98 if tag == "S":99 return f"S{num:03d}"100 else:101 return f"{tag}{num:02d}"102 if ss.isdigit():103 return f"Q{int(ss):02d}"104 return ss105 106def canonical_author(s: Optional[str]) -> str:107 if not s:108 return ""109 return str(s).strip().upper()110 111def sanitize_key(s: str) -> str:112 s2 = re.sub(r'\s+', '_', str(s))113 s2 = re.sub(r'[^A-Za-z0-9_\-\.]+', '', s2)114 return s2[:120] if len(s2) > 120 else s2115 116# ========================= UTILS FS / MATCH =========================117def ensure_dir(p: Path): p.mkdir(parents=True, exist_ok=True)118 119_ID_RE = re.compile(r'(KIC|TIC)[ _-]?(\d+)', re.IGNORECASE)120def match_only_target(path: Path, only: Optional[str]) -> bool:121 """Permite substring/regex simple sobre la ruta/stem; también acepta ID numérico."""122 if not only:123 return True124 s = str(path)125 try:126 if re.search(only, s, re.IGNORECASE):127 return True128 except re.error:129 if only.lower() in s.lower():130 return True131 if str(only).isdigit() and re.search(rf"{int(only)}", s):132 return True133 m = _ID_RE.search(path.stem) or _ID_RE.search(s)134 if m and re.search(str(only), f"{m.group(1)} {m.group(2)}", re.IGNORECASE):135 return True136 return False137 138# ============================== CURATE ==============================139KEPLER_RE = re.compile(r"^(KIC_\d+)(?:_Q(\d{1,2}))?(?:_author-([A-Za-z0-9\-]+))?_part(\d{3})$")140TESS_RE = re.compile(r"^(TIC_\d+)(?:_S(\d{1,3}))?(?:_author-([A-Za-z0-9\-]+))?_part(\d{3})$")141 142def parse_from_path(csv_path: Path) -> Dict[str, Optional[str]]:143 try:144 label = csv_path.parent.name145 mission = csv_path.parent.parent.name146 except Exception:147 label = None; mission = None148 stem = csv_path.stem149 m = KEPLER_RE.match(stem)150 if m:151 tid = m.group(1).replace("_"," ")152 q = int(m.group(2)) if m.group(2) else None153 auth = m.group(3) or "Kepler"154 return {"mission": mission or "Kepler","label": label,"target_id": tid,155 "segment": (f"Q{q:02d}" if q is not None else ""), "author": auth, "part": int(m.group(4))}156 m = TESS_RE.match(stem)157 if m:158 tid = m.group(1).replace("_"," ")159 s = int(m.group(2)) if m.group(2) else None160 auth= m.group(3) or "SPOC"161 return {"mission": mission or "TESS","label": label,"target_id": tid,162 "segment": (f"S{s:03d}" if s is not None else ""), "author": auth, "part": int(m.group(4))}163 return {"mission": mission, "label": label, "target_id": stem.split("_",1)[0].replace("_"," "),164 "segment":"", "author":None, "part":None}165 166def lower_map(df: pd.DataFrame) -> Dict[str, str]: return {c.lower(): c for c in df.columns}167TIME_CANDS = ["time","bkjd","btjd","jd","bjd","time_bjd","time_tjd","mjd"]168FLUX_PREF = ["pdcsap_flux","kspsap_flux","sap_flux","flux","psf_flux","aperture_flux","flux_raw","aperture_sum","net_flux"]169FLUX_ERRS = ["pdcsap_flux_err","kspsap_flux_err","sap_flux_err","flux_err","psf_flux_err","aperture_flux_err"]170QUALITYS = QUALITY_CANDS171 172def pick_time_col(df: pd.DataFrame) -> Optional[str]:173 lm = lower_map(df)174 for k in TIME_CANDS:175 if k in lm: return lm[k]176 return None177 178def pick_flux_cols(df: pd.DataFrame) -> Tuple[Optional[str], Optional[str], Optional[str]]:179 lm = lower_map(df)180 fcol = None181 for k in FLUX_PREF:182 if k in lm: fcol = lm[k]; break183 if fcol is None:184 for c in df.columns:185 if c.lower() not in TIME_CANDS and pd.api.types.is_numeric_dtype(df[c]):186 fcol = c; break187 ferr = None188 for k in FLUX_ERRS:189 if k in lm: ferr = lm[k]; break190 qcol = None191 for k in QUALITYS:192 if k in lm: qcol = lm[k]; break193 return fcol, ferr, qcol194 195def load_meta_for(csv_path: Path) -> Optional[Dict]:196 meta = csv_path.with_suffix(".meta.json")197 if meta.exists():198 try:199 return json.loads(meta.read_text(encoding="utf-8"))200 except Exception:201 return None202 return None203 204def reconstruct_time_from_meta(n_rows: int, meta: Dict) -> Optional[np.ndarray]:205 if n_rows <= 0 or meta is None: return None206 def _get(k):207 v = meta.get(k)208 try: return float(v) if v is not None else None209 except Exception: return None210 tstart = _get("TSTART") or _get("LC_START")211 tstop = _get("TSTOP") or _get("LC_END")212 dt = _get("TIMEDEL")213 if n_rows == 1 and tstart is not None: return np.array([tstart], float)214 if tstart is not None and tstop is not None and tstop >= tstart:215 return np.linspace(tstart, tstop, n_rows, dtype=float)216 if tstart is not None and dt is not None:217 return tstart + dt * np.arange(n_rows, dtype=float)218 return None219 220@dataclass221class CurateResult:222 file: str; out_file: Optional[str]; status: str; reason: str223 mission: Optional[str]; label: Optional[str]; target_id: Optional[str]224 n_rows_in: int; n_rows_out: int; flux_col_used: Optional[str]; time_reconstructed: bool225 226def curate_one(csv_path: Path, raw_root: Path, curated_root: Path, min_rows: int) -> CurateResult:227 meta_info = parse_from_path(csv_path)228 try:229 df = pd.read_csv(csv_path)230 except UnicodeDecodeError:231 df = pd.read_csv(csv_path, encoding="latin-1")232 except Exception as e:233 return CurateResult(csv_path.as_posix(), None, "READ_ERROR", str(e),234 meta_info["mission"], meta_info["label"], meta_info["target_id"], 0, 0, None, False)235 n_in = int(len(df))236 time_col = pick_time_col(df)237 fcol, ferr, qcol = pick_flux_cols(df)238 t_reco = False239 if time_col is None:240 meta = load_meta_for(csv_path)241 tvec = reconstruct_time_from_meta(n_in, meta) if meta else None242 if tvec is None or len(tvec) != n_in:243 return CurateResult(csv_path.as_posix(), None, "NO_TIME", "sin 'time' y no se pudo reconstruir",244 meta_info["mission"], meta_info["label"], meta_info["target_id"],245 n_in, 0, fcol, False)246 df.insert(0, "time", tvec); time_col = "time"; t_reco = True247 if fcol is None:248 return CurateResult(csv_path.as_posix(), None, "NO_FLUX", "no se encontró columna de flujo útil",249 meta_info["mission"], meta_info["label"], meta_info["target_id"], n_in, 0, None, t_reco)250 251 out = pd.DataFrame()252 out["time"] = pd.to_numeric(df[time_col], errors="coerce")253 flux_raw = pd.to_numeric(df[fcol], errors="coerce")254 out["flux_raw"] = flux_raw255 med = float(np.nanmedian(flux_raw)) if np.isfinite(np.nanmedian(flux_raw)) else np.nan256 out["flux"] = flux_raw / med if (np.isfinite(med) and med != 0) else flux_raw257 out["flux_err"] = (pd.to_numeric(df[ferr], errors="coerce")/med) if (ferr and np.isfinite(med) and med!=0) else (pd.to_numeric(df[ferr], errors="coerce") if ferr else np.nan)258 out["quality"] = pd.to_numeric(df[qcol], errors="coerce") if qcol else 0259 out["mission"] = meta_info["mission"] or ""260 out["label"] = meta_info["label"] or ""261 out["target_id"]=meta_info["target_id"] or ""262 out["segment"] = meta_info["segment"] or ""263 out["author"] = meta_info["author"] or ""264 out["flux_source"] = fcol265 out = out.replace([np.inf,-np.inf], np.nan).dropna(subset=["time","flux"]).sort_values("time").reset_index(drop=True)266 n_out = int(len(out))267 if n_out < min_rows:268 return CurateResult(csv_path.as_posix(), None, "TOO_FEW_ROWS", f"{n_out} < min_rows={min_rows}",269 meta_info["mission"], meta_info["label"], meta_info["target_id"], n_in, n_out, fcol, t_reco)270 try: rel = csv_path.relative_to(raw_root)271 except Exception: rel = Path(out["mission"].iloc[0] or "UNK")/ (out["label"].iloc[0] or "UNK")/ csv_path.name272 out_path = (curated_root / rel); ensure_dir(out_path.parent); out.to_csv(out_path, index=False)273 return CurateResult(csv_path.as_posix(), out_path.as_posix(), "OK", "", meta_info["mission"], meta_info["label"],274 meta_info["target_id"], n_in, n_out, fcol, t_reco)275 276def run_curate(raw_root: Path, curated_root: Path, only_target: Optional[str],277 workers: int, min_rows: int, limit: Optional[int]) -> Path:278 ensure_dir(curated_root)279 files = [p for p in raw_root.glob("**/*.csv") if match_only_target(p, only_target)]280 files = sorted(files)281 if limit: files = files[:limit]282 if not files:283 print(f"[WARN] No hay CSV en {raw_root} (filtro only-target={only_target}).")284 return curated_root285 results: List[CurateResult] = []286 with ThreadPoolExecutor(max_workers=max(1,workers)) as ex:287 futs = [ex.submit(curate_one, p, raw_root, curated_root, min_rows) for p in files]288 for f in tqdm(as_completed(futs), total=len(futs), desc="Curando"):289 try: results.append(f.result())290 except Exception as e:291 results.append(CurateResult("(unknown)", None, "EXC", str(e), None,None,None,0,0,None,False))292 rep = pd.DataFrame([asdict(r) for r in results])293 ensure_dir(curated_root / "reports")294 rep.to_csv(curated_root / "reports" / "curation_report.csv", index=False)295 status_counts = rep["status"].value_counts(dropna=False).to_dict()296 print("[CURATE] status:", status_counts)297 return curated_root298 299# ====================== FEATURES (como training) ======================300def robust_median_trend(y: np.ndarray, window: int) -> np.ndarray:301 if window % 2 == 0: window += 1302 s = pd.Series(y)303 tr = s.rolling(window=window, center=True, min_periods=max(5, window//5)).median().to_numpy()304 med = np.nanmedian(y)305 return np.where(np.isfinite(tr), tr, med)306 307def flatten(time: np.ndarray, flux: np.ndarray, window: int = ROLL_WINDOW) -> Tuple[np.ndarray, np.ndarray]:308 trend = robust_median_trend(flux, window)309 trend = np.where(trend == 0, 1.0, trend)310 return flux / trend, trend311 312def fold_phase(time: np.ndarray, t0: float, period: float):313 return ((time - t0 + 0.5*period) % period) / period - 0.5314 315def bin_phase(phase: np.ndarray, flux: np.ndarray, n_bins: int):316 edges = np.linspace(-0.5, 0.5, n_bins + 1)317 idx = np.digitize(phase, edges) - 1318 x = np.array([np.nanmean(phase[idx==i]) for i in range(n_bins)])319 y = np.array([np.nanmedian(flux[idx==i]) for i in range(n_bins)])320 m = np.isfinite(x) & np.isfinite(y)321 return x[m], y[m]322 323def cadence_minutes(time: np.ndarray) -> Optional[float]:324 if time is None or len(time) < 3: return None325 dt = np.diff(np.sort(time)); dt = dt[np.isfinite(dt)]326 if len(dt) == 0: return None327 return float(np.nanmedian(dt) * 24 * 60)328 329def bls_best_for_periods(bls: BoxLeastSquares, periods: np.ndarray, fracs: np.ndarray) -> Dict[str, float]:330 best = {"power": -np.inf}331 for frac in fracs:332 durations = periods * frac333 m = durations < periods334 if not np.any(m): continue335 res = bls.power(periods[m], durations[m])336 pwr = np.asarray(res.power)337 k = int(np.nanargmax(pwr))338 if pwr[k] > best["power"]:339 best = {"period": float(np.asarray(res.period)[k]),340 "duration": float(np.asarray(res.duration)[k]),341 "t0": float(np.asarray(res.transit_time)[k]),342 "power": float(pwr[k])}343 return best344 345def run_bls_two_stage(t: np.ndarray, f: np.ndarray, pmin: float, pmax: float, fracs: np.ndarray) -> Dict[str, float]:346 # pmin ajustado por span/2.5 (como en training para partes)347 span = float(np.nanmax(t) - np.nanmin(t)) if t.size else 0.0348 pmin_eff = max(pmin, min(pmax, span/2.5) if span>0 else pmin)349 pmax_eff = max(pmin_eff, pmax)350 351 bls = BoxLeastSquares(t, f)352 grid = np.geomspace(pmin_eff, pmax_eff, 900)353 stacks = []354 for frac in fracs:355 durations = grid * frac356 m = durations < grid357 if not np.any(m): continue358 stacks.append(np.asarray(bls.power(grid[m], durations[m]).power))359 if not stacks: raise RuntimeError("BLS: sin combinaciones válidas (parte).")360 agg = np.sum(np.stack(stacks, axis=0), axis=0)361 top_idx = np.argsort(agg)[-2:][::-1]362 best = {"power": -np.inf}363 for idx in top_idx:364 p0 = grid[idx]; pf = np.linspace(p0*0.98, p0*1.02, 700)365 cand = bls_best_for_periods(bls, pf, fracs)366 if cand and cand["power"] > best["power"]: best = cand367 if best["power"] == -np.inf: raise RuntimeError("BLS no encontró señal (parte).")368 return best369 370def choose_best_alias(t, flat, best, n_bins=BINS) -> Dict[str, Any]:371 baseP = best["period"]; cand=[]372 for tag, P in [("P/2",baseP/2.0), ("P",baseP), ("2P",baseP*2.0)]:373 if P<=0: continue374 frac = max(EPS, best["duration"]/baseP); dur= frac*P375 phase = fold_phase(t, best["t0"], P)376 xb,yb = bin_phase(phase, flat, n_bins)377 w = max(frac*1.2, 2/n_bins)378 in0 = np.abs(xb-0.0)<=w; inS = np.abs(np.abs(xb)-0.5)<=w379 med0 = float(np.nanmedian(yb[in0])) if np.any(in0) else 1.0380 medS = float(np.nanmedian(yb[inS])) if np.any(inS) else 1.0381 depth = max(0.0, (1.0 - med0)*1e6); sec = max(0.0, (1.0 - medS)*1e6)382 score = depth - 5.0*sec383 cand.append({"tag":tag,"period":P,"duration":dur,"depth_ppm":depth,"secondary_ppm":sec,"score":score})384 return max(cand, key=lambda d: d["score"])385 386def estimate_depth_snr(phase: np.ndarray, flat: np.ndarray, period: float, duration: float) -> Tuple[Optional[float], Optional[float], Optional[float]]:387 if period is None or duration is None: return None, None, None388 frac = max(0.01, min(0.2, duration/period)); win = 1.5*frac389 in_tr = np.abs(phase) <= win/2; oot = np.abs(phase) >= win390 if np.sum(in_tr)<10 or np.sum(oot)<20: return None, None, None391 depth = np.nanmedian(flat[oot]) - np.nanmedian(flat[in_tr])392 rms = np.nanstd(flat[oot])393 snr = depth / (rms / np.sqrt(np.sum(in_tr))) if (rms and rms>0) else None394 return float(depth*1e6), (float(snr) if snr is not None else None), float(rms)395 396# ====================== METADATA (por PARTE) ======================397STAR_COLS = ["stellar_radius_rsun","stellar_mass_msun","stellar_teff_k","stellar_logg_cgs"]398 399def load_meta_star_props(meta_csv: Optional[str]) -> Optional[pd.DataFrame]:400 if not meta_csv:401 return None402 p = Path(meta_csv)403 if not p.exists():404 print(f"[WARN] No encuentro metadata: {p.as_posix()}")405 return None406 try:407 df = pd.read_csv(p)408 except Exception as e:409 print(f"[WARN] Error leyendo metadata: {e}")410 return None411 412 for c in ["mission", "label", "target_id", "segment", "author", "quarter", "sector", "campaign"]:413 if c in df.columns:414 df[c] = df[c].astype(str).str.strip()415 416 if "segment" not in df.columns:417 def _seg(row):418 for k, pref in [("quarter","Q"), ("sector","S"), ("campaign","C")]:419 v = row.get(k)420 try:421 if pd.notnull(v):422 val = int(float(v))423 return f"{pref}{val:03d}" if pref == "S" else f"{pref}{val:02d}"424 except Exception:425 pass426 return ""427 df["segment"] = df.apply(_seg, axis=1)428 429 base_keys = ["mission", "label", "target_id", "segment"]430 if "author" in df.columns:431 base_keys.append("author")432 433 keep = [c for c in (base_keys + ["period_days"] + STAR_COLS) if c in df.columns]434 if "target_id" not in keep:435 return None436 437 out = df[keep].copy()438 439 dedup_keys = [c for c in ["mission","label","target_id","segment","author"] if c in out.columns]440 out = out.sort_values(dedup_keys).drop_duplicates(dedup_keys, keep="first")441 442 out["_tid"] = out["target_id"].apply(lambda x: canonical_tid(x))443 out["_seg"] = out["segment"].apply(lambda x: canonical_segment(x) if pd.notnull(x) else "")444 if "author" in out.columns:445 out["_auth"] = out["author"].apply(lambda x: canonical_author(x) if pd.notnull(x) else "")446 else:447 out["_auth"] = ""448 return out449 450def lookup_meta_row(enrich: pd.DataFrame, tid_real: str, seg_real: str, auth_real: str) -> Optional[pd.Series]:451 if enrich is None or enrich.empty:452 return None453 groups = []454 if auth_real is not None:455 g1 = enrich[(enrich["_tid"] == tid_real) & (enrich["_seg"] == seg_real) & (enrich["_auth"] == auth_real)]456 groups.append(g1)457 g2 = enrich[(enrich["_tid"] == tid_real) & (enrich["_seg"] == seg_real)]458 groups.append(g2)459 g3 = enrich[(enrich["_tid"] == tid_real)]460 groups.append(g3)461 for g in groups:462 if len(g):463 if "period_days" in g.columns:464 notnull = g[g["period_days"].notnull()]465 if len(notnull):466 return notnull.iloc[0]467 return g.iloc[0]468 return None469 470# ======================= LECTURA CSV por parte =======================471META_BASIC = ["mission","label","target_id","segment","author"]472 473def read_part_csv(p: Path) -> Optional[pd.DataFrame]:474 try:475 df = pd.read_csv(p)476 except Exception:477 return None478 if not {"time","flux"}.issubset(df.columns):479 return None480 qcol = next((c for c in QUALITY_CANDS if c in df.columns), None)481 if qcol and qcol != "quality":482 df["quality"] = pd.to_numeric(df[qcol], errors="coerce")483 elif "quality" not in df.columns:484 df["quality"] = 0485 need = set(META_BASIC) - set(df.columns)486 if need:487 try:488 label = p.parent.name489 mission = p.parent.parent.name490 except Exception:491 label = ""; mission = ""492 stem = p.stem493 m = KEPLER_RE.match(stem) or TESS_RE.match(stem)494 tid, seg, auth = "", "", ""495 if m:496 tid = m.group(1).replace("_"," ")497 if m.re is KEPLER_RE:498 seg = f"Q{int(m.group(2)):02d}" if m.group(2) else ""499 auth = m.group(3) or ("Kepler" if "Kepler" in mission else "")500 else:501 seg = f"S{int(m.group(2)):03d}" if m.group(2) else ""502 auth = m.group(3) or ("SPOC" if "TESS" in mission else "")503 if "mission" not in df: df["mission"] = mission504 if "label" not in df: df["label"] = label505 if "target_id" not in df: df["target_id"] = tid506 if "segment" not in df: df["segment"] = seg507 if "author" not in df: df["author"] = auth508 return df509 510# ===================== FEATURES + ARTEFACTOS (parte) =====================511def compute_features_and_artifacts_per_part(p: Path,512 enrich: Optional[pd.DataFrame],513 sigma_clip: float, qfilter: str, qmask: int,514 pmin: float, pmax: float,515 artifacts_dir: Optional[Path]) -> Optional[Dict[str, Any]]:516 df = read_part_csv(p)517 if df is None or df.empty:518 return None519 520 if "quality" in df.columns:521 q = df["quality"].fillna(0).astype(float)522 if qfilter == "good":523 df = df[q <= 0]524 elif qfilter == "bitmask" and qmask > 0:525 df = df[(q.fillna(0).astype(int) & int(qmask)) == 0]526 527 t = pd.to_numeric(df["time"], errors="coerce").to_numpy(float)528 f = pd.to_numeric(df["flux"], errors="coerce").to_numpy(float)529 m = np.isfinite(t) & np.isfinite(f)530 t, f = t[m], f[m]531 if t.size < 300:532 return None533 534 med = np.nanmedian(f)535 if np.isfinite(med) and med != 0:536 f = f / med537 538 if sigma_clip and sigma_clip > 0:539 med = np.nanmedian(f); mad = 1.4826*np.nanmedian(np.abs(f-med))540 if np.isfinite(mad) and mad > 0:541 lo, hi = med - sigma_clip*mad, med + sigma_clip*mad542 keep = np.isfinite(f) & (f>=lo) & (f<=hi)543 t, f = t[keep], f[keep]544 if t.size < 300: return None545 546 f_flat, trend = flatten(t, f, ROLL_WINDOW)547 548 mission = str(df["mission"].iloc[0]) if "mission" in df.columns else ""549 label = str(df["label"].iloc[0]) if "label" in df.columns else ""550 tid = str(df["target_id"].iloc[0]) if "target_id" in df.columns else ""551 seg = str(df["segment"].iloc[0]) if "segment" in df.columns else ""552 auth = str(df["author"].iloc[0]) if "author" in df.columns else ""553 554 # Restricción por metadata555 pmin_eff, pmax_eff = pmin, pmax556 rstar = mstar = teff = logg = None557 if enrich is not None:558 prefer_prefix = "KIC" if tid.upper().startswith("KIC") else ("TIC" if tid.upper().startswith("TIC") else None)559 ct = canonical_tid(tid, prefer_prefix=prefer_prefix)560 cs = canonical_segment(seg)561 ca = canonical_author(auth)562 row = lookup_meta_row(enrich, ct, cs, ca)563 if row is not None:564 if "period_days" in row and pd.notnull(row.get("period_days")):565 try:566 Pcat = float(row["period_days"])567 if P_MIN <= Pcat <= P_MAX:568 pmin_eff, pmax_eff = 0.9*Pcat, 1.1*Pcat569 except Exception:570 pass571 teff = float(row.get("stellar_teff_k")) if pd.notnull(row.get("stellar_teff_k")) else None572 logg = float(row.get("stellar_logg_cgs")) if pd.notnull(row.get("stellar_logg_cgs")) else None573 rstar = float(row.get("stellar_radius_rsun")) if pd.notnull(row.get("stellar_radius_rsun")) else None574 mstar = float(row.get("stellar_mass_msun")) if pd.notnull(row.get("stellar_mass_msun")) else None575 576 # BLS (con heurística span/2.5)577 try:578 best = run_bls_two_stage(t, f_flat, pmin_eff, pmax_eff, DUR_FRACS)579 except Exception:580 return None581 582 alias = choose_best_alias(t, f_flat, best, BINS)583 P, D, power = alias["period"], alias["duration"], best["power"]584 phase = fold_phase(t, best["t0"], P)585 depth_ppm, snr, rms_oot = estimate_depth_snr(phase, f_flat, P, D)586 587 frac = max(EPS, D/P if (P and D) else 0.02); w=frac/2588 in1 = np.abs(phase) <= w; in2 = np.abs(np.abs(phase)-0.5)<=w589 odd_even_ppm = secondary_ppm = None590 if np.sum(in1)>=5 and np.sum(in2)>=5:591 d1 = 1 - np.nanmedian(f_flat[in1]); d2 = 1 - np.nanmedian(f_flat[in2])592 odd_even_ppm = float((d1-d2)*1e6)593 secondary_ppm = float((1-np.nanmedian(f_flat[in2]))*1e6)594 595 span_d = float(np.nanmax(t) - np.nanmin(t))596 ntr = int(span_d/P) if (P and P>0) else None597 rp_rs = math.sqrt(max(depth_ppm,0.0)/1e6) if (depth_ppm is not None and np.isfinite(depth_ppm)) else None598 planet_re = (rp_rs * rstar * 109.1) if (rp_rs is not None and rstar is not None) else None599 600 # ---------- Artefactos ----------601 if artifacts_dir is not None:602 # clave: <tid>__<segment>__<author>__<stem>603 key = sanitize_key("__".join([x for x in [tid, seg, auth, p.stem] if x]))604 out_k = artifacts_dir / key605 ensure_dir(out_k)606 607 plt.style.use("dark_background")608 609 # 1) lc_raw.png610 fig = plt.figure(figsize=(9,4.5), dpi=120)611 ax = plt.gca()612 ax.plot(t, f, lw=0.6)613 ax.set_xlabel("Tiempo (días)")614 ax.set_ylabel("Flujo normalizado")615 ax.set_title(f"{tid} {seg} {auth} — Parte")616 ax.grid(alpha=.2)617 fig.tight_layout()618 fig.savefig(out_k / "lc_raw.png")619 plt.close(fig)620 621 # 2) lc_dtr.png622 fig = plt.figure(figsize=(9,4.5), dpi=120)623 ax = plt.gca()624 ax.plot(t, f_flat, lw=0.5)625 ax.set_xlabel("Tiempo (días)")626 ax.set_ylabel("Flujo aplanado")627 ax.set_title(f"{tid} {seg} {auth} — Detrended")628 ax.grid(alpha=.2)629 fig.tight_layout()630 fig.savefig(out_k / "lc_dtr.png")631 plt.close(fig)632 633 # 3) periodogram.png (agregado usado internamente)634 # volvemos a calcular el agregado para dibujar635 bls = BoxLeastSquares(t, f_flat)636 grid = np.geomspace(max(P_MIN, min(P_MAX, (span_d/2.5) if span_d>0 else P_MIN)), max(P_MAX, P_MAX), 900)637 stacks = []638 for frac_d in DUR_FRACS:639 durations = grid * frac_d640 msk = durations < grid641 if np.any(msk):642 stacks.append(np.asarray(bls.power(grid[msk], durations[msk]).power))643 if stacks:644 agg = np.sum(np.stack(stacks, axis=0), axis=0)645 fig = plt.figure(figsize=(9,4.5), dpi=120)646 ax = plt.gca()647 ax.plot(grid, agg, lw=0.9)648 ax.axvline(P, ls="--", lw=1.0)649 ax.set_xscale("log")650 ax.set_xlabel("Período (días)")651 ax.set_ylabel("Potencia (BLS agregada)")652 ax.set_title(f"{tid} {seg} {auth} — Periodograma")653 ax.grid(alpha=.2)654 fig.tight_layout()655 fig.savefig(out_k / "periodogram.png")656 plt.close(fig)657 658 # 4) phase.png659 xb, yb = bin_phase(phase, f_flat, BINS)660 fig = plt.figure(figsize=(9,4.5), dpi=120)661 ax = plt.gca()662 ax.scatter(phase, f_flat, s=2, alpha=0.25)663 ax.plot(xb, yb, lw=1.2)664 ax.set_xlabel("Fase")665 ax.set_ylabel("Flujo aplanado")666 ax.set_title(f"{tid} {seg} — Fase (P={P:.5f} d, dur={D:.4f} d)")667 ax.grid(alpha=.2)668 fig.tight_layout()669 fig.savefig(out_k / "phase.png")670 plt.close(fig)671 672 # 5) odd_even.png673 if np.sum(in1) > 0:674 k = np.floor((t - best["t0"])/P + 0.5).astype(int)675 odd = (k % 2) != 0676 fig = plt.figure(figsize=(9,4.5), dpi=120)677 ax = plt.gca()678 ax.scatter(phase[in1 & (~odd)], f_flat[in1 & (~odd)], s=5, alpha=0.7, label="pares")679 ax.scatter(phase[in1 & odd], f_flat[in1 & odd], s=5, alpha=0.7, label="impares")680 ax.set_xlabel("Fase")681 ax.set_ylabel("Flujo aplanado")682 ax.set_title(f"{tid} {seg} {auth} — Odd/Even")683 ax.legend()684 ax.grid(alpha=.2)685 fig.tight_layout()686 fig.savefig(out_k / "odd_even.png")687 plt.close(fig)688 689 # 6) marks.png (ventanas de tránsito en el dominio del tiempo)690 fig = plt.figure(figsize=(9,4.5), dpi=120)691 ax = plt.gca()692 ax.plot(t, f_flat, lw=0.5)693 if P and D:694 tmin, tmax = float(np.min(t)), float(np.max(t))695 n_lo = int(np.floor((tmin - best["t0"]) / P)) - 1696 n_hi = int(np.ceil((tmax - best["t0"]) / P)) + 1697 n_total = n_hi - n_lo + 1698 step = max(1, int(np.ceil(n_total / 120)))699 for n in range(n_lo, n_hi+1, step):700 center = best["t0"] + n*P701 ax.axvspan(center - D/2, center + D/2, color="tab:blue", alpha=0.15, lw=0)702 ax.set_xlabel("Tiempo (días)")703 ax.set_ylabel("Flujo aplanado")704 ax.set_title(f"{tid} {seg} — Ventanas de tránsito")705 ax.grid(alpha=.2)706 fig.tight_layout()707 fig.savefig(out_k / "marks.png")708 plt.close(fig)709 710 manifest = {711 "mission": mission, "label": label,712 "target_id": tid, "segment": seg, "author": auth,713 "csv": str(p.as_posix()),714 "n_rows": int(len(t)),715 "period_days": P, "duration_days": D, "t0": float(best["t0"]),716 }717 (out_k / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")718 719 # ---------- Features ----------720 return {721 "out_file": p.as_posix(),722 "mission": mission, "label": label, "target_id": tid,723 "segment": seg, "author": auth,724 "n_rows": int(len(t)),725 "cadence_min": cadence_minutes(t),726 "span_days": span_d,727 "bls_power": power,728 "period_days": P,729 "duration_days": D,730 "depth_ppm": depth_ppm,731 "secondary_ppm": secondary_ppm,732 "snr_transit": snr,733 "odd_even_delta_ppm": odd_even_ppm,734 "rms_oot": rms_oot,735 "n_transits_est": ntr,736 "rp_over_rs": rp_rs,737 "planet_radius_re": planet_re,738 "teff_k": teff,739 "logg_cgs": logg,740 "stellar_radius_rsun": rstar,741 "stellar_mass_msun": mstar742 }743 744def extract_features_per_part(p: Path, enrich: Optional[pd.DataFrame],745 sigma_clip: float, qfilter: str, qmask: int,746 pmin: float, pmax: float) -> Optional[Dict[str, Any]]:747 # wrapper sin artefactos (por compatibilidad si lo necesitas)748 return compute_features_and_artifacts_per_part(749 p, enrich, sigma_clip, qfilter, qmask, pmin, pmax, artifacts_dir=None750 )751 752# =========================== MODELO / CLI ===========================753DEFAULT_FEATURE_ORDER = [754 "n_rows","cadence_min","span_days","bls_power","period_days","duration_days",755 "depth_ppm","secondary_ppm","snr_transit","odd_even_delta_ppm","rms_oot",756 "n_transits_est","rp_over_rs","planet_radius_re","teff_k","logg_cgs",757 "stellar_radius_rsun","stellar_mass_msun"758]759 760def load_model(pkl_path: Path, thr_json: Path):761 clf = joblib.load(pkl_path)762 thr = json.loads(thr_json.read_text(encoding="utf-8")) if thr_json.exists() else {"default_threshold_value": 0.5}763 feat_names = thr.get("feature_names")764 order = feat_names if isinstance(feat_names, list) and len(feat_names)>0 else DEFAULT_FEATURE_ORDER765 return clf, thr, order766 767def predict_one_row(model, row: Dict[str,Any], feature_order: List[str]) -> float:768 X = pd.DataFrame([{k: row.get(k, np.nan) for k in feature_order}])769 return float(model.predict_proba(X)[:,1][0])770 771def build_cli():772 p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)773 # Entradas774 p.add_argument("--raw-root", type=str, default=None, help="Raíz de CSV crudos (se curan).")775 p.add_argument("--curated-root", type=str, default=None, help="Si lo das, saltas el curado.")776 p.add_argument("--out", type=str, default="PredOutParts", help="Raíz de salidas.")777 p.add_argument("--only-target", type=str, default=None, help="Filtro substring/regex sobre ruta/stem (p. ej. '11336883', 'part003', 'S031.*part005').")778 p.add_argument("--limit", type=int, default=None, help="Máximo de CSV crudos a curar o partes a procesar (ordenado).")779 p.add_argument("--workers", type=int, default=8)780 p.add_argument("--min-rows", type=int, default=100, help="Mínimo de filas tras curado.")781 # Filtros/feats (como training)782 p.add_argument("--sigma-clip", type=float, default=6.0)783 p.add_argument("--quality-filter", type=str, default="good", choices=["none","good","bitmask"])784 p.add_argument("--quality-bitmask", type=int, default=0)785 # Modelo786 p.add_argument("--model", type=str, default=str(DEFAULT_PKL))787 p.add_argument("--thresholds", type=str, default=str(DEFAULT_THR))788 p.add_argument("--aggregate-by-target", type=str, default="max", choices=["max","mean"],789 help="Agregación de probabilidades por target (post-predicción).")790 # Metadata por PARTE791 p.add_argument("--metadata", type=str, default=None, help="CSV por parte (mission,label,target_id,segment,author,period_days,props estelares)")792 # Artefactos793 p.add_argument("--no-artifacts", action="store_true", help="Si se pasa, NO se guardan PNGs/manifest por parte.")794 return p.parse_args()795 796# =============================== MAIN ===============================797def main():798 warnings.filterwarnings("ignore")799 800 args = build_cli()801 out_root = Path(args.out).resolve(); ensure_dir(out_root)802 803 # 1) CURATE (si hay raw-root) → curated_dir804 if args.curated_root:805 curated_dir = Path(args.curated_root).resolve()806 if not curated_dir.exists():807 print(f"[ERROR] --curated-root no existe: {curated_dir}"); sys.exit(1)808 print(f"[INFO] Usando curated existente: {curated_dir.as_posix()}")809 else:810 if not args.raw_root:811 print("[ERROR] Debes indicar --raw-root o --curated-root."); sys.exit(1)812 raw_root = Path(args.raw_root).resolve()813 curated_dir = out_root / "curated"814 curated_dir = run_curate(raw_root, curated_dir, args.only_target, args.workers, args.min_rows, args.limit)815 816 # 2) Cargar modelo + thresholds + orden de features817 model, thr, feature_order = load_model(Path(args.model), Path(args.thresholds))818 thr_val = float(thr.get("thresholds",{}).get("best_f1",{}).get("thr", thr.get("default_threshold_value", 0.5)))819 820 # 3) Metadata por parte821 enrich = load_meta_star_props(args.metadata)822 823 # 4) Listar partes a procesar824 files = sorted([p for p in curated_dir.glob("**/*.csv") if match_only_target(p, args.only_target)])825 if args.limit:826 files = files[:int(args.limit)]827 if not files:828 print(f("[WARN] No hay CSV curados en {curated_dir} (filtro only-target={args.only_target})."))829 sys.exit(0)830 831 # Carpeta de artefactos por parte832 artifacts_dir = None if args.no_artifacts else (out_root / "artifacts_parts")833 if artifacts_dir is not None:834 ensure_dir(artifacts_dir)835 836 # 5) Features + Predicción837 rows_feat, preds, man = [], [], []838 for p in tqdm(files, desc="Features (per-part)", unit="file"):839 f = compute_features_and_artifacts_per_part(840 p, enrich,841 sigma_clip=args.sigma_clip,842 qfilter=args.quality_filter,843 qmask=args.quality_bitmask,844 pmin=P_MIN, pmax=P_MAX,845 artifacts_dir=artifacts_dir846 )847 if f is None:848 continue849 850 proba = predict_one_row(model, f, feature_order)851 rows_feat.append(f)852 preds.append({853 "mission": f["mission"], "label": f["label"], "target_id": f["target_id"],854 "segment": f["segment"], "author": f["author"],855 "file": f["out_file"], "proba_confirmed": proba,856 "pred_label": ("CONFIRMED" if proba>=thr_val else "FALSE_POSITIVE"),857 "period_days": f.get("period_days"),858 "duration_days": f.get("duration_days"),859 "depth_ppm": f.get("depth_ppm"),860 "secondary_ppm": f.get("secondary_ppm"),861 "snr_transit": f.get("snr_transit"),862 "odd_even_delta_ppm": f.get("odd_even_delta_ppm"),863 "rms_oot": f.get("rms_oot"),864 "n_transits_est": f.get("n_transits_est"),865 "rp_over_rs": f.get("rp_over_rs"),866 "planet_radius_re": f.get("planet_radius_re"),867 "teff_k": f.get("teff_k"),868 "logg_cgs": f.get("logg_cgs"),869 "stellar_radius_rsun": f.get("stellar_radius_rsun"),870 "stellar_mass_msun": f.get("stellar_mass_msun")871 })872 man.append({"csv": p.as_posix(), "kind":"curated"})873 874 if rows_feat:875 pd.DataFrame(rows_feat).to_csv(out_root / "features_part.csv", index=False)876 pd.DataFrame(preds).to_csv(out_root / "predictions_part.csv", index=False)877 pd.DataFrame(man).to_csv(out_root / "manifest_part.csv", index=False)878 print(f"[OK] per-part: {len(preds)} predicciones → { (out_root/'predictions_part.csv').as_posix() }")879 880 # 6) Agregado por target (max/mean)881 agg = (pd.DataFrame(preds)882 .groupby(["mission","label","target_id"])["proba_confirmed"]883 .agg(args.aggregate_by_target).reset_index()884 .rename(columns={"proba_confirmed": f"proba_{args.aggregate_by_target}"}))885 agg["pred_label"] = np.where(agg[f"proba_{args.aggregate_by_target}"]>=thr_val,"CONFIRMED","FALSE_POSITIVE")886 agg.to_csv(out_root / "predictions_part_by_target.csv", index=False)887 print(f"[OK] agregado por target ({args.aggregate_by_target}) → { (out_root/'predictions_part_by_target.csv').as_posix() }")888 else:889 print("[WARN] per-part: no se obtuvieron features de ningún archivo.")890 891 print(f"\n[UMBRAL USADO] {thr_val:.3f}")892 print("[FIN]")893 894if __name__ == "__main__":895 main()896 