Files
SimurgArray/src/simurg/harmonize/combine.py
T
kemalandClaude Opus 5.5 2e8478cc6a
ci / test (3.12) (push) Failing after 8s
ci / test (3.13) (push) Failing after 10s
P6 complete: optional VCF fields, SNV+indel merging, auxiliary MNV loci, PLINK 2 .pgen
- gtc-to-vcf --info-fields ASSAY_TYPE,NORM_BIN,GENTRAIN,CLUSTER_SEP,ILMN_STRAND,PROBE_IDS (--egt) and
  --extra-format X,Y,NORMX,NORMY,THETA,R, in per-sample VCFs and the --merge BCF (OUT-012, plan §8.3);
  equal to gtc2vcf's values (THETA/R missing where both normalized intensities are 0, K-023)
- --merge-snv-indel (OUT-021, EDGE-013): alleles extended to the longest REF; SNV probes read the base at POS
- --auxiliary-loci (OUT-035, EDGE-015): rules (a)-(e), E_AUX_INVALID; rule (d) uses the last plus-strand base
  for minus-strand designs (spec amended, as GTCtoVCF)
- export plink --format bed|pgen|both: PLINK 2 fixed-width .pgen/.pvar/.psam; plink2 reads it back to a .bed
  byte-identical to ours
- cohort store keeps raw/normalized intensities for the BCF path; tests (unit + gtc2vcf oracle); docs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
2026-09-28 14:29:42 +01:00

125 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Genotypes of VCF sites from probe calls (spec OUT-018, OUT-031, OUT-032, OUT-037; plan §8.3).
Single-probe sites map the A/B call straight to allele indices. Sites with several probes use the exact candidate
algorithm of OUT-031: enumerate every genotype over {REF} ∪ ALT with the site's ploidy, predict each probe's call
from the alleles' signal classes (Infinium II: dye group of the extended base; Infinium I and indels: exact allele,
no signal otherwise) and keep the candidates consistent with every called probe.
[OSS: Illumina GTCtoVCF ``GenotypeFormat.RecordCombiner`` (Apache-2.0) — the consistency idea; the class model and
the ploidy-aware enumeration are ours.]
"""
from __future__ import annotations
from dataclasses import dataclass
from itertools import combinations_with_replacement
from simurg.harmonize.sites import GREEN, RED, ProbeSite, Site
NC, AA, AB, BB = 0, 1, 2, 3
CALL_TEXT = {NC: "NC", AA: "AA", AB: "AB", BB: "BB"}
def signal_class(p: ProbeSite, allele: str) -> str | None:
"""OUT-031 step 1."""
a, b = p.alleles
if p.kind == "snv": # SNV probes read the single base at POS; alleles may be extended by --merge-snv-indel
z, a0, b0 = allele[:1], a[:1], b[:1]
if p.infinium == 2:
grp = RED if z in RED else GREEN
if a0 in grp:
return "A"
if b0 in grp:
return "B"
return None
return "A" if z == a0 else "B" if z == b0 else None
if allele == a:
return "A"
if allele == b:
return "B"
return None
def predicted_call(p: ProbeSite, genotype: tuple[str, ...]) -> int:
classes = sorted(c for c in (signal_class(p, z) for z in genotype) if c is not None)
if not classes:
return NC
u = set(classes)
if u == {"A"}:
return AA
if u == {"B"}:
return BB
return AB
@dataclass(frozen=True)
class Outcome:
alleles: tuple[int, ...] | None # allele indices (sorted) or None for missing
reason: str # "" | "het_in_haploid" | W_MERGE_* | W_INF1_NC_CONFLICT
def single(site: Site, call: int, ploidy: int) -> Outcome:
p = site.probes[0]
if call == NC:
return Outcome(None, "")
ia, ib = site.allele_index(p.alleles[0]), site.allele_index(p.alleles[1])
if ploidy == 1:
if call == AB:
return Outcome(None, "het_in_haploid")
return Outcome((ia if call == AA else ib,), "")
pair = {AA: (ia, ia), AB: (ia, ib), BB: (ib, ib)}[call]
return Outcome(tuple(sorted(pair)), "")
def merged(site: Site, calls: tuple[int, ...], ploidy: int, inf1_nc_informative: bool = False) -> Outcome:
"""OUT-031 steps 3–6."""
if all(c == NC for c in calls):
return Outcome(None, "W_MERGE_ALL_NC")
alleles = [site.ref, *site.alts]
cands = list(combinations_with_replacement(range(len(alleles)), ploidy))
keep = [
g
for g in cands
if all(
c == NC or predicted_call(p, tuple(alleles[i] for i in g)) == c
for p, c in zip(site.probes, calls, strict=True)
)
]
reason = ""
if inf1_nc_informative:
for p, c in zip(site.probes, calls, strict=True):
if c == NC and p.infinium == 1:
sub = [g for g in keep if all(signal_class(p, alleles[i]) is None for i in g)]
if sub:
keep = sub
else:
reason = "W_INF1_NC_CONFLICT"
if len(keep) == 1:
return Outcome(keep[0], reason)
return Outcome(None, "W_MERGE_INCONSISTENT" if not keep else "W_MERGE_AMBIGUOUS")
def genotype(site: Site, calls: tuple[int, ...], ploidy: int, inf1_nc_informative: bool = False) -> Outcome:
if len(site.probes) == 1:
return single(site, calls[0], ploidy)
key = (calls, ploidy, inf1_nc_informative)
hit = site.memo.get(key)
if hit is None:
hit = site.memo[key] = merged(site, calls, ploidy, inf1_nc_informative)
assert isinstance(hit, Outcome)
return hit
def gt_text(o: Outcome, ploidy: int) -> str:
if o.alleles is None:
return "." if ploidy == 1 else "./."
return "/".join(str(i) for i in o.alleles)
def failure_message(site: Site, calls: tuple[int, ...], reason: str) -> str:
what = {"W_MERGE_INCONSISTENT": "inconsistent", "W_MERGE_AMBIGUOUS": "ambiguous", "W_MERGE_ALL_NC": "all no-calls"}
probes = ", ".join(
f"{p.name} ({'InfiniumI' if p.infinium == 1 else 'InfiniumII'}): {CALL_TEXT[c]}"
for p, c in zip(site.probes, calls, strict=True)
)
return f"Failed to combine genotypes ({what.get(reason, reason)}) - {probes}"