Files
SimurgArray/src/simurg/formats/bcf.py
T
kemal 4d190d559e 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
2026-09-28 14:29:42 +01:00

165 lines
6.2 KiB
Python

"""BCF 2.2 writer and CSI index (spec OUT-040, OUT-038).
Records are built from typed values; the file is BGZF-compressed and indexed with a CSI index (min_shift 14,
depth 5, the pseudo-bin carries record counts). [DOC: VCF/BCF specification v4.3 §6 (BCF2), CSI index format —
layouts only]
"""
from __future__ import annotations
import struct
from collections.abc import Sequence
from simurg.formats.bgzf import PSEUDO_BIN, BgzfWriter, bgzf_compress, reg2bin
INT8, INT16, INT32, FLOAT, CHAR = 1, 2, 3, 5, 7
FLOAT_MISSING = struct.pack("<I", 0x7F800001)
INT8_VECTOR_END = -127
INT8_MISSING = -128
def _type_byte(n: int, t: int) -> bytes:
if n < 15:
return bytes([(n << 4) | t])
return bytes([(15 << 4) | t]) + typed_ints([n])
def typed_ints(values: Sequence[int]) -> bytes:
"""Smallest integer type that holds every value (missing/vector-end sentinels excluded from the range)."""
lo, hi = (min(values), max(values)) if values else (0, 0)
if lo >= -120 and hi <= 127:
return _type_byte(len(values), INT8) + struct.pack(f"<{len(values)}b", *values)
if lo >= -32760 and hi <= 32767:
return _type_byte(len(values), INT16) + struct.pack(f"<{len(values)}h", *values)
return _type_byte(len(values), INT32) + struct.pack(f"<{len(values)}i", *values)
def typed_string(s: str) -> bytes:
b = s.encode()
return _type_byte(len(b), CHAR) + b
def float_bytes(values: Sequence[float | None]) -> bytes:
out = bytearray()
for v in values:
out += FLOAT_MISSING if v is None or v != v else struct.pack("<f", v)
return bytes(out)
def gt_bytes(genotypes: Sequence[tuple[int, ...] | None], ploidies: Sequence[int]) -> tuple[int, bytes]:
"""FORMAT/GT for all samples: width = max ploidy; unphased (a+1)<<1; missing 0; haploid padded with END."""
width = max(1, max(ploidies))
vals: list[int] = []
for g, p in zip(genotypes, ploidies, strict=True):
p = max(p, 1)
cells = [0] * p if g is None else [(a + 1) << 1 for a in g]
cells += [INT8_VECTOR_END] * (width - len(cells))
vals += cells
return width, bytes(vals_to_int8(vals))
def vals_to_int8(vals: Sequence[int]) -> bytes:
return struct.pack(f"<{len(vals)}b", *vals)
def encode_record(
contig_id: int,
pos0: int,
rlen: int,
vid: str,
alleles: Sequence[str],
filter_id: int,
n_sample: int,
fmt: Sequence[tuple[int, bytes, bytes]],
info: Sequence[tuple[int, bytes]] = (),
) -> bytes:
"""``fmt``: (format dictionary index, type-descriptor bytes, per-sample payload) in FORMAT order;
``info``: (dictionary index, typed value) pairs."""
shared = bytearray()
shared += struct.pack("<iii", contig_id, pos0, rlen)
shared += FLOAT_MISSING # QUAL
shared += struct.pack("<I", (len(alleles) << 16) | len(info)) # n_allele << 16 | n_info
shared += struct.pack("<I", (len(fmt) << 24) | n_sample)
shared += typed_string(vid if vid else ".")
for a in alleles:
shared += typed_string(a)
shared += typed_ints([filter_id])
for key, value in info:
shared += typed_ints([key]) + value
indiv = bytearray()
for key, desc, payload in fmt:
indiv += typed_ints([key]) + desc + payload
return struct.pack("<II", len(shared), len(indiv)) + bytes(shared) + bytes(indiv)
class CsiBuilder:
"""CSI index (min_shift 14, depth 5) with per-bin loffset and the pseudo-bin."""
def __init__(self, n_ref: int) -> None:
self.refs: list[dict[str, object]] = [{} for _ in range(n_ref)]
def add(self, rid: int, beg: int, end: int, vstart: int, vend: int) -> None:
r = self.refs[rid]
if not r:
r.update(bins={}, n=0, first=vstart, last=vend)
bins: dict[int, list[list[int]]] = r["bins"] # type: ignore[assignment]
b = reg2bin(beg, end)
chunks = bins.setdefault(b, [])
if chunks and chunks[-1][1] == vstart:
chunks[-1][1] = vend
else:
chunks.append([vstart, vend])
r["n"] = int(r["n"]) + 1 # type: ignore[call-overload]
r["last"] = vend
def encode(self) -> bytes:
out = bytearray(b"CSI\x01") + struct.pack("<iii", 14, 5, 0) + struct.pack("<i", len(self.refs))
for r in self.refs:
if not r:
out += struct.pack("<i", 0)
continue
bins: dict[int, list[list[int]]] = r["bins"] # type: ignore[assignment]
out += struct.pack("<i", len(bins) + 1)
for b in sorted(bins):
ch = bins[b]
out += struct.pack("<IQi", b, ch[0][0], len(ch))
for s, e in ch:
out += struct.pack("<QQ", s, e)
out += struct.pack("<IQi", PSEUDO_BIN, 0, 2) + struct.pack("<QQQQ", r["first"], r["last"], r["n"], 0)
out += struct.pack("<Q", 0)
return bgzf_compress(bytes(out))
class BcfWriter:
"""Stream BCF records into BGZF; header text must follow VCF 4.3 with PASS as the first FILTER line."""
def __init__(self, fh: object, header_text: str) -> None:
self.w = BgzfWriter(fh) # type: ignore[arg-type]
text = header_text.encode() + b"\0"
self.w.write_record(b"BCF\x02\x02" + struct.pack("<I", len(text)) + text)
def write(self, record: bytes) -> tuple[int, int]:
return self.w.write_record(record)
def close(self) -> None:
self.w.close()
def header_dictionaries(header_text: str) -> tuple[dict[str, int], dict[str, int]]:
"""(string dictionary: FILTER/INFO/FORMAT IDs → index, contig dictionary: name → index) as htslib builds them."""
strings: dict[str, int] = {"PASS": 0}
contigs: dict[str, int] = {}
for line in header_text.splitlines():
for kind in ("FILTER", "INFO", "FORMAT"):
if line.startswith(f"##{kind}=<ID="):
key = line[len(kind) + 7 :].split(",", 1)[0].split(">", 1)[0]
strings.setdefault(key, len(strings))
if line.startswith("##contig=<ID="):
name = line[13:].split(",", 1)[0].split(">", 1)[0]
contigs.setdefault(name, len(contigs))
return strings, contigs
def typed_floats(values: Sequence[float]) -> bytes:
return _type_byte(len(values), FLOAT) + float_bytes(values)