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

+38 -7
View File
@@ -308,27 +308,45 @@ class SampleInfo:
class CohortStore:
"""Disk-backed (samples × loci) matrices: genotypes (uint8), BAF and GenCall scores (float32, optional)."""
def __init__(self, root: Path, n_samples: int, n_loci: int, *, baf: bool, scores: bool, lrr: bool = False) -> None:
def __init__(
self,
root: Path,
n_samples: int,
n_loci: int,
*,
baf: bool,
scores: bool,
lrr: bool = False,
intensities: bool = False,
) -> None:
self.root, self.shape = root, (n_samples, n_loci)
root.mkdir(parents=True, exist_ok=True)
self.gt = np.lib.format.open_memmap(root / "gt.npy", "w+", np.uint8, self.shape)
self.baf = np.lib.format.open_memmap(root / "baf.npy", "w+", np.float32, self.shape) if baf else None
self.score = np.lib.format.open_memmap(root / "score.npy", "w+", np.float32, self.shape) if scores else None
self.lrr = np.lib.format.open_memmap(root / "lrr.npy", "w+", np.float32, self.shape) if lrr else None
# raw and normalized intensities (needs root/lookup.npy: normalization-transform index per locus)
self.rx = self.ry = self.nx = self.ny = None
if intensities:
self.rx = np.lib.format.open_memmap(root / "rx.npy", "w+", np.uint16, self.shape)
self.ry = np.lib.format.open_memmap(root / "ry.npy", "w+", np.uint16, self.shape)
self.nx = np.lib.format.open_memmap(root / "nx.npy", "w+", np.float32, self.shape)
self.ny = np.lib.format.open_memmap(root / "ny.npy", "w+", np.float32, self.shape)
def close(self) -> None:
for name in ("gt", "baf", "score", "lrr"):
for name in ("gt", "baf", "score", "lrr", "rx", "ry", "nx", "ny"):
a = getattr(self, name)
if a is not None:
a.flush()
self.gt = self.baf = self.score = self.lrr = None # type: ignore[assignment]
self.rx = self.ry = self.nx = self.ny = None
shutil.rmtree(self.root, ignore_errors=True)
def _load_one(task: tuple[int, str, str, str, bool, bool, bool]) -> SampleInfo:
def _load_one(task: tuple[int, str, str, str, bool, bool, bool, bool]) -> SampleInfo:
from simurg.formats.gtc import read_gtc
i, sid, path, root, want_baf, want_scores, want_lrr = task
i, sid, path, root, want_baf, want_scores, want_lrr, want_int = task
info = SampleInfo(sid, path)
try:
g = read_gtc(path)
@@ -356,6 +374,15 @@ def _load_one(task: tuple[int, str, str, str, bool, bool, bool]) -> SampleInfo:
sc = np.load(Path(root) / "score.npy", mmap_mode="r+")
sc[i] = g.genotype_scores
sc.flush()
if want_int:
from simurg.signal.normalization.apply import normalize
lookup = np.load(Path(root) / "lookup.npy")
nx, ny = normalize(g.raw_x, g.raw_y, lookup, g.normalization_transforms)
for name, arr in (("rx", g.raw_x), ("ry", g.raw_y), ("nx", nx), ("ny", ny)):
mm = np.load(Path(root) / f"{name}.npy", mmap_mode="r+")
mm[i] = arr
mm.flush()
except SimurgError as e:
info.error = f"{e.code}: {e.message}"
except Exception as e:
@@ -372,11 +399,15 @@ def load_cohort(
scores: bool,
workers: int,
lrr: bool = False,
lookup: npt.NDArray[np.int32] | None = None,
) -> tuple[CohortStore, list[SampleInfo]]:
"""Read GTCs (sample_id, path) into a :class:`CohortStore` with a process pool."""
store = CohortStore(tmp, len(samples), n_loci, baf=baf, scores=scores, lrr=lrr)
"""Read GTCs (sample_id, path) into a :class:`CohortStore` with a process pool. With ``lookup`` (the manifest's
normalization-transform index per locus) raw and normalized intensities are stored too."""
store = CohortStore(tmp, len(samples), n_loci, baf=baf, scores=scores, lrr=lrr, intensities=lookup is not None)
if lookup is not None:
np.save(tmp / "lookup.npy", lookup)
store.gt.flush()
tasks = [(i, sid, p, str(tmp), baf, scores, lrr) for i, (sid, p) in enumerate(samples)]
tasks = [(i, sid, p, str(tmp), baf, scores, lrr, lookup is not None) for i, (sid, p) in enumerate(samples)]
if workers <= 1 or len(tasks) <= 1:
infos = [_load_one(t) for t in tasks]
else: