"""Parse GSEA tables → canonical `GseaResult`. Mapping-driven, not name-trusting: a column *mapping* (canonical field -> source column) decides what becomes what. The default mapping is for clusterProfiler GSEA output. The leading_edge trap: clusterProfiler emits a column literally named `leading_edge` that holds a stats string ("tags=77%, list=28%, signal=77%") — NOT genes. The genes live in `core_enrichment` (slash-separated). The default mapping therefore reads genes from `core_enrichment`, exactly as the canonical schema intends. """ from __future__ import annotations import math from pathlib import Path from typing import Optional import pandas as pd from core.schema import GseaResult, PathwayRow # Canonical field -> source column (clusterProfiler GSEA). pval/size/leading_edge optional. CLUSTERPROFILER_MAPPING: dict[str, str] = { "pathway": "Description", "nes": "NES", "padj": "p.adjust", "pval": "pvalue", "size": "setSize", "leading_edge": "core_enrichment", # genes — NOT the literal `leading_edge` column } REQUIRED_FIELDS = ("pathway", "nes", "padj") def default_mapping() -> dict[str, str]: """The clusterProfiler GSEA column mapping (a copy, safe to mutate).""" return dict(CLUSTERPROFILER_MAPPING) def read_table(path: str | Path, sep: Optional[str] = None) -> pd.DataFrame: """Read csv/tsv/xlsx into a DataFrame, dispatching on file extension.""" path = Path(path) suffix = path.suffix.lower() if suffix in (".xlsx", ".xls"): return pd.read_excel(path) if sep is None: sep = "\t" if suffix in (".tsv", ".txt") else "," return pd.read_csv(path, sep=sep) def _split_genes(value) -> list[str]: """Split a clusterProfiler `core_enrichment` cell ('IFIT1/STAT1/...') into symbols.""" if value is None or (isinstance(value, float) and math.isnan(value)): return [] return [g.strip() for g in str(value).split("/") if g.strip()] def _to_float(value) -> Optional[float]: try: f = float(value) except (TypeError, ValueError): return None # Reject NaN AND ±inf: R/clusterProfiler can serialize infinities as 'Inf', and an infinite # NES would poison the |NES|-weighted centroid (→ NaN vector → scipy.linkage crash). return f if math.isfinite(f) else None def _to_int(value) -> Optional[int]: f = _to_float(value) return None if f is None else int(round(f)) def _infer_collection(pathways: list[str]) -> Optional[str]: """Infer the MSigDB collection from a shared pathway-name prefix (e.g. HALLMARK_).""" prefixes = {p.split("_", 1)[0] for p in pathways if "_" in p} return prefixes.pop() if len(prefixes) == 1 else None def apply_mapping( df: pd.DataFrame, mapping: Optional[dict[str, str]] = None, name: str = "result", collection: Optional[str] = None, ) -> GseaResult: """Transform a DataFrame into a canonical `GseaResult` via a column mapping. Deterministic: code applies the mapping; nothing here rewrites data rows. Rows with a non-numeric/missing NES or padj are dropped (warned by the engine downstream). """ mapping = mapping or default_mapping() for field in REQUIRED_FIELDS: col = mapping.get(field) if col is None or col not in df.columns: raise ValueError( f"required field '{field}' maps to column '{col}', which is absent. " f"available columns: {list(df.columns)}" ) has_pval = mapping.get("pval") in df.columns has_size = mapping.get("size") in df.columns has_le = mapping.get("leading_edge") in df.columns rows: list[PathwayRow] = [] for _, r in df.iterrows(): nes = _to_float(r[mapping["nes"]]) padj = _to_float(r[mapping["padj"]]) pathway = r[mapping["pathway"]] if nes is None or padj is None or pd.isna(pathway): continue # un-parseable / blank row rows.append(PathwayRow( pathway=str(pathway).strip(), nes=nes, padj=padj, pval=_to_float(r[mapping["pval"]]) if has_pval else None, size=_to_int(r[mapping["size"]]) if has_size else None, leading_edge=_split_genes(r[mapping["leading_edge"]]) if has_le else [], )) if collection is None: collection = _infer_collection([row.pathway for row in rows]) return GseaResult(name=name, collection=collection, rows=rows) def parse_gsea_file( path: str | Path, mapping: Optional[dict[str, str]] = None, name: Optional[str] = None, collection: Optional[str] = None, sep: Optional[str] = None, ) -> GseaResult: """Read a GSEA file and normalize it to a `GseaResult` (default = clusterProfiler mapping).""" path = Path(path) name = name or path.stem return apply_mapping(read_table(path, sep=sep), mapping=mapping, name=name, collection=collection) def to_canonical_dict(result: GseaResult) -> dict: """JSON-serializable canonical dict, ready to ship over the MCP boundary.""" return result.model_dump()