"""CN/LOH event extraction, statistics and filters — spec CYTO-006 … CYTO-021, CYTO-027. Pure functions, no I/O. From a per-chromosome Viterbi path: runs of non-baseline states become events; statistics per CYTO-027; effective size per CYTO-010 (gaps > 150 × the chromosome's median spacing replaced by the median); QUAL from the mean posterior of the called state class (CYTO-012); mosaic fraction from the BAF split or, failing that, LRR (CYTO-018/019/042); post-filters in the order of CYTO-015; per-type thresholds (CYTO-007/008) and promotion/low fraction (CYTO-020/021). """ from __future__ import annotations import math from dataclasses import dataclass, field import numpy as np import numpy.typing as npt from simurg.cn.hmm import State F64 = npt.NDArray[np.float64] I64 = npt.NDArray[np.int64] GAP_FACTOR = 150 MIN_MF_PROBES = 20 LOH_EXPECTED_HETS = 5.0 # CYTO-041: expected heterozygous probes needed to call a loss complete LOH_HET_RATIO = 0.25 # CYTO-041: observed / expected heterozygotes below which a loss is complete TYPES = ("del", "dup", "loh", "gainloh", "mdel", "mdup", "mloh") DEFAULTS = {"del": (10, 25_000), "dup": (10, 50_000), "loh": (500, 3_000_000), "gainloh": (300, 500_000), "mdel": (10, 25_000), "mdup": (10, 50_000), "mloh": (500, 3_000_000)} # fmt: skip CALL_KEY = {"DEL": "del", "DUP": "dup", "LOH": "loh", "GAINLOH": "gainloh", "MDEL": "mdel", "MDUP": "mdup", "MLOH": "mloh"} # fmt: skip @dataclass class Event: chrom: str start: int # first affected base S (1-based) = first probe position end: int # last affected base E = last probe position i0: int # probe index range in the chromosome arrays [i0, i1) i1: int call: str # DEL | DUP | LOH | GAINLOH | MDEL | MDUP | MLOH cn: int state: str quality: float = 0.0 effective_size: int = 0 nr: float = math.nan lrd: float = math.nan baf_dev: float = math.nan percent_het: float = math.nan lrr_median: float = math.nan mosaic_fraction: float | None = None high_fraction: bool = False filters: list[str] = field(default_factory=list) flags: list[str] = field(default_factory=list) @property def mosaic(self) -> bool: return self.call.startswith("M") @property def probes(self) -> int: return self.i1 - self.i0 @property def size(self) -> int: return self.end - self.start + 1 @property def svtype(self) -> str: return {"MDEL": "DEL", "MDUP": "DUP", "MLOH": "LOH", "GAINLOH": "LOH"}.get(self.call, self.call) @property def loh_type(self) -> str | None: if self.call == "LOH": return "AOH" if self.call == "GAINLOH": return "AOH" if self.call == "MLOH": return "CNLOH" return None @property def id_prefix(self) -> str: return {"DEL": "DEL", "DUP": "DUP", "LOH": "AOH", "GAINLOH": "AOH", "MDEL": "MosaicDEL", "MDUP": "MosaicDUP", "MLOH": "CNLOH"}[self.call] # fmt: skip def median_spacing(pos: I64) -> float: d = np.diff(np.sort(pos)) d = d[d > 0] return float(np.median(d)) if len(d) else 1.0 def effective_size(pos: I64, m: float) -> int: """CYTO-010 for the probes of one event (sorted positions).""" if len(pos) < 2: return 1 s = np.diff(pos).astype(np.float64) gap = s > GAP_FACTOR * m return int((pos[-1] - pos[0] + 1) - float((s[gap] - m).sum())) def extract(chrom: str, path: npt.NDArray[np.int32], states: list[State], pos: I64) -> list[Event]: """Runs of non-baseline states, merged across runs of the same call type (mosaic fraction steps).""" out: list[Event] = [] if len(path) == 0: return out br = np.flatnonzero(np.diff(path) != 0) + 1 starts, ends = np.r_[0, br], np.r_[br, len(path)] for s, e in zip(starts.tolist(), ends.tolist(), strict=True): st = states[int(path[s])] if not st.call: continue if out and out[-1].i1 == s and out[-1].call == st.call and out[-1].cn == st.cn: out[-1].i1 = e out[-1].end = int(pos[e - 1]) continue out.append(Event(chrom, int(pos[s]), int(pos[e - 1]), s, e, st.call, st.cn, st.name)) return out def _mf_from_baf(call: str, baf: F64, baf_sd: float = 0.0, pfb: F64 | None = None) -> float | None: """CYTO-018 with the CYTO-041 refinements [OURS]: the split is corrected for BAF noise (the median |BAF − 0.5| of unsplit heterozygotes is 0.6745·σ_b), and a loss whose expected heterozygotes are absent is complete (MF 1).""" fin = np.isfinite(baf) mid = fin & (baf > 0.1) & (baf < 0.9) b = baf[mid] if call == "MDEL" and pfb is not None and fin.sum() >= MIN_MF_PROBES: p = np.clip(np.where(np.isfinite(pfb), pfb, 0.5), 0.0, 1.0)[fin] expected = float((2 * p * (1 - p)).sum()) if expected >= LOH_EXPECTED_HETS and len(b) < LOH_HET_RATIO * expected: return 1.0 if len(b) < MIN_MF_PROBES: return None d_obs = float(np.median(np.abs(b - 0.5))) d = math.sqrt(max(0.0, d_obs**2 - (0.6745 * baf_sd) ** 2)) if call == "MDEL": f = 2 * d / (0.5 + d) elif call == "MDUP": f = 2 * d / (0.5 - d) if d < 0.5 else 1.0 else: f = 2 * d return float(min(1.0, max(0.0, f))) def _mf_from_lrr(call: str, lrr: F64, gamma: float) -> float | None: x = lrr[np.isfinite(lrr)] if len(x) < MIN_MF_PROBES or gamma <= 0: return None f = abs(2 ** (float(np.median(x)) / gamma) - 1) return float(min(1.0, max(0.0, f))) def annotate_stats( ev: Event, pos: I64, lrr: F64, baf: F64, geno: npt.NDArray[np.uint8], post: F64, states: list[State], m: float, gamma: float, haploid: bool, baf_sd: float = 0.0, pfb: F64 | None = None, ) -> None: # fmt: skip """CYTO-010/012/018/027 statistics in place (``lrr`` unsmoothed).""" sl = slice(ev.i0, ev.i1) x = lrr[sl] fx = x[np.isfinite(x)] ev.nr = float(np.median(2.0**fx)) if len(fx) else math.nan ev.lrd = float(np.std(fx)) if len(fx) > 1 else math.nan ev.lrr_median = float(np.median(fx)) if len(fx) else math.nan g = geno[sl] called = g > 0 ev.percent_het = float(100.0 * (g == 2).sum() / called.sum()) if called.any() else math.nan b = baf[sl][(g == 2) & np.isfinite(baf[sl])] ev.baf_dev = float(np.std(b)) if len(b) > 1 else math.nan ev.effective_size = effective_size(pos[sl], m) cls = [j for j, st in enumerate(states) if st.call == ev.call] p = float(post[sl][:, cls].sum(axis=1).mean()) if cls else 0.0 ev.quality = 60.0 if p >= 1.0 else float(min(60, max(0, round(-10 * math.log10(max(1e-300, 1 - p)))))) if ev.mosaic: mf = None if haploid else _mf_from_baf(ev.call, baf[sl], baf_sd, None if pfb is None else pfb[sl]) ev.mosaic_fraction = mf if mf is not None else _mf_from_lrr(ev.call, x, gamma) @dataclass class Thresholds: probes: dict[str, int] size_bp: dict[str, int] max_mosaic_fraction: float = 1.0 min_mosaic_fraction: float = 0.10 filter_qual: int = 10 lrr_direction_min_frac: float = 0.6 @classmethod def defaults(cls) -> Thresholds: return cls({k: v[0] for k, v in DEFAULTS.items()}, {k: v[1] for k, v in DEFAULTS.items()}) def as_header(self, extra: dict[str, object]) -> str: parts = [] for k in ("del", "dup", "loh", "gainloh", "mdel", "mdup", "mloh"): parts += [f"min_{k}_probes={self.probes[k]}", f"min_{k}_size_bp={self.size_bp[k]}"] parts += [f"{k}={v}" for k, v in extra.items()] return ";".join(parts) def post_filter(events: list[Event], lrr_by_event: list[F64], th: Thresholds, counts: dict[str, int]) -> list[Event]: """CYTO-015 steps 1–3 and CYTO-020 promotion; per-type thresholds are applied by :func:`apply_thresholds`.""" kept: list[Event] = [] for ev, x in zip(events, lrr_by_event, strict=True): fx = x[np.isfinite(x)] if ev.call in ("DEL", "MDEL") and len(fx) and (fx < 0).mean() < th.lrr_direction_min_frac: counts["lrr_direction"] = counts.get("lrr_direction", 0) + 1 continue if ev.call in ("DUP", "MDUP", "GAINLOH") and len(fx) and (fx > 0).mean() < th.lrr_direction_min_frac: counts["lrr_direction"] = counts.get("lrr_direction", 0) + 1 continue kept.append(ev) # short LOH between copy-neutral neighbours (no other event adjacent on either side) out: list[Event] = [] for i, ev in enumerate(kept): if ev.call in ("LOH", "MLOH") and ev.size < 500_000: left = kept[i - 1] if i > 0 and kept[i - 1].chrom == ev.chrom else None right = kept[i + 1] if i + 1 < len(kept) and kept[i + 1].chrom == ev.chrom else None if not (left and left.i1 == ev.i0) and not (right and right.i0 == ev.i1): counts["short_loh"] = counts.get("short_loh", 0) + 1 continue out.append(ev) merged: list[Event] = [] for ev in out: prev = merged[-1] if merged else None if (prev and prev.chrom == ev.chrom and prev.call == ev.call and prev.cn == ev.cn and ev.i0 - prev.i1 <= 3 and (ev.start - prev.end) <= 0.1 * (ev.end - prev.start + 1)): # fmt: skip prev.i1, prev.end = ev.i1, ev.end counts["merged"] = counts.get("merged", 0) + 1 continue merged.append(ev) for ev in merged: # CYTO-020 promotion if ev.mosaic and ev.mosaic_fraction is not None and ev.mosaic_fraction >= th.max_mosaic_fraction: ev.call = {"MDEL": "DEL", "MDUP": "DUP", "MLOH": "LOH"}[ev.call] ev.high_fraction = True return merged def apply_thresholds(events: list[Event], th: Thresholds, counts: dict[str, int]) -> list[Event]: out = [] for ev in events: k = CALL_KEY[ev.call] if ev.probes < th.probes[k] or ev.effective_size < th.size_bp[k]: counts["below_threshold"] = counts.get("below_threshold", 0) + 1 continue out.append(ev) return out def set_filters(events: list[Event], th: Thresholds, sample_noisy: bool) -> None: """CYTO-013 FILTER values.""" for ev in events: f = [] if ev.quality < th.filter_qual: f.append(f"Q{th.filter_qual}") if sample_noisy: f.append("SampleQuality") if ev.mosaic and ev.mosaic_fraction is not None and ev.mosaic_fraction < th.min_mosaic_fraction: f.append("LowFraction") ev.filters = f or ["PASS"]