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
This commit is contained in:
kemal committed 2026-09-28 14:29:42 +01:00
1 parent d3add008f7
commit 1001bf951e
13 files changed
+611 -50

No files matched your search

+10 -7
View File
@@ -22,13 +22,16 @@ 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" and p.infinium == 2 and len(allele) == 1:
grp = RED if allele in RED else GREEN
if a in grp:
return "A"
if b in grp:
return "B"
return None
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:
+125 -2
View File
@@ -78,7 +78,7 @@ class Site:
@property
def id(self) -> str:
return (";" if self.kind == "indel" else ",").join(p.name for p in self.probes)
return ("," if self.kind == "snv" else ";").join(p.name for p in self.probes)
def allele_index(self, allele: str) -> int:
return 0 if allele == self.ref else 1 + self.alts.index(allele)
@@ -285,7 +285,13 @@ def map_locus(
strand == "-", audit.reason), audit # fmt: skip
def group_sites(probes: Sequence[ProbeSite], contig_order: dict[str, int], *, unsquash: bool = False) -> list[Site]:
def group_sites(
probes: Sequence[ProbeSite],
contig_order: dict[str, int],
*,
unsquash: bool = False,
merge_snv_indel: bool = False,
) -> list[Site]:
"""OUT-030/OUT-033/OUT-034: probes sharing CHROM/POS/REF (and, for indels, the ALT) form one site; ALT alleles in
order of first appearance over BPM order; records sorted by contig (.fai order), POS, REF, ALT, ID."""
groups: dict[tuple[str, int, str, str, str], list[ProbeSite]] = {}
@@ -303,5 +309,122 @@ def group_sites(probes: Sequence[ProbeSite], contig_order: dict[str, int], *, un
sites += [Site(contig, pos, ref, [x for x in p.alleles if x != ref], [p], kind) for p in ps]
else:
sites.append(Site(contig, pos, ref, alts, ps, kind))
if merge_snv_indel:
sites = _merge_snv_indel(sites)
sites.sort(key=lambda s: (contig_order.get(s.contig, 1 << 30), s.pos, s.ref, ",".join(s.alts), s.id))
return sites
def _merge_snv_indel(sites: list[Site]) -> list[Site]:
"""OUT-021 ``--merge-snv-indel``: SNV and indel sites at the same CHROM/POS become one multi-allelic record.
Every allele is extended to the longest REF (VCF representation); SNV probes still read the base at POS."""
from dataclasses import replace
by_pos: dict[tuple[str, int], list[Site]] = {}
for st in sites:
by_pos.setdefault((st.contig, st.pos), []).append(st)
out: list[Site] = []
for group in by_pos.values():
kinds = {st.kind for st in group}
longest = max((st.ref for st in group), key=len)
if not ({"snv", "indel"} <= kinds) or not all(longest.startswith(st.ref) for st in group):
out += group
continue
probes = []
for st in group:
suffix = longest[len(st.ref) :]
probes += [
replace(p, ref=longest, alleles=(p.alleles[0] + suffix, p.alleles[1] + suffix)) for p in st.probes
]
probes.sort(key=lambda p: p.index)
alts: list[str] = []
for p in probes:
alts += [x for x in p.alleles if x != longest and x not in alts]
out.append(Site(group[0].contig, group[0].pos, longest, alts, probes, "mixed"))
return out
# ------------------------------------------------------------------------------------------------ auxiliary loci
@dataclass
class AuxRecord:
name: str
chrom: str
pos: int
ref: str
alts: list[str]
def apply_auxiliary(
probes: list[ProbeSite],
aux: Sequence[AuxRecord],
fetch: Fetch,
contig_of: Callable[[str], str | None],
strand_of: Callable[[int], str],
) -> tuple[list[ProbeSite], list[str]]:
"""OUT-035: replace the plus-strand site of MNV probes by the true variant. Returns (probes, errors); every
violated rule is one error message (E_AUX_INVALID). ``strand_of(locus index)`` gives the RefStrand.
Rule (d): the base the probe interrogates is the MNV's first base on the plus strand for plus-strand designs and
its last base for minus-strand designs (the probe extends from the other side) [OSS: GTCtoVCF, Apache-2.0]."""
from dataclasses import replace
by_name = {p.name: k for k, p in enumerate(probes)}
at_site: dict[tuple[str, int], int] = {}
for p in probes:
at_site[(p.contig, p.pos)] = at_site.get((p.contig, p.pos), 0) + 1
out = list(probes)
errors: list[str] = []
for r in aux:
where = f"auxiliary record {r.name} ({r.chrom}:{r.pos})"
k = by_name.get(r.name)
if k is None:
errors.append(f"{where}: not a probe of the manifest (or the probe was skipped)")
continue
p = probes[k]
if len(r.alts) != 1:
errors.append(f"{where}: (a) exactly one ALT allele is required")
continue
alt = r.alts[0]
if not (len(r.ref) == len(alt) >= 2 and r.ref != alt):
errors.append(f"{where}: (b) not a multi-nucleotide variant (len(REF) = len(ALT) >= 2, REF != ALT)")
continue
if at_site[(p.contig, p.pos)] > 1:
errors.append(f"{where}: (c) the probe is not the only assay at its site")
continue
minus = strand_of(p.index) == "-"
def pick(x: str, minus: bool = minus) -> str:
return x[-1] if minus else x[0]
designed = [a[-1] if minus else a[0] for a in p.alleles] if p.kind == "snv" else []
mapping = {}
for d in designed:
hits = [x for x in (r.ref, alt) if pick(x) == d]
if len(hits) == 1:
mapping[d] = hits[0]
if len(mapping) != 2 or mapping[designed[0]] == mapping[designed[1]]:
errors.append(f"{where}: (d) REF/ALT do not match the probe's alleles {p.alleles[0]}/{p.alleles[1]}")
continue
contig = contig_of(r.chrom)
if contig is None or fetch(contig, r.pos - 1, r.pos - 1 + len(r.ref)) != r.ref.upper():
errors.append(f"{where}: (e) REF does not match the reference genome")
continue
out[k] = replace(
p, contig=contig, pos=r.pos, ref=r.ref.upper(), alleles=(mapping[designed[0]], mapping[designed[1]]),
reason="aux_locus",
) # fmt: skip
return out, errors
def read_auxiliary(path: str) -> list[AuxRecord]:
import gzip
opener = gzip.open if path.endswith(".gz") else open
out = []
with opener(path, "rt") as fh:
for line in fh:
if line.startswith("#") or not line.strip():
continue
f = line.rstrip("\n").split("\t")
out.append(AuxRecord(f[2], f[0], int(f[1]), f[3].upper(), [a.upper() for a in f[4].split(",")]))
return out