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:
1 parent
d3add008f7
commit
1001bf951e
13 files changed
+611
-50
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user