P4: unit tests for calling/BAF/LRR/sex/CLI; vendor validation script

- tests/unit/test_genotype.py: fuzzy memberships incl. NaN rules, calls
  at cluster centres, intensity threshold, BAF/LRR interpolation and
  NaN pattern, LogR-dev locus set, sex rules v1/v2, TOP-strand base
  calls, invariant dates, end-to-end `simurg genotype` (summary schema,
  pass-through columns, thread/layout determinism, overwrite refusal)
- tests/oracle/test_p4_genotype.py: 36 GSA samples x 2 versions
  byte-identical to the oracle
- scripts/validate_p4_large.py + cloud task p4: compare every vendor
  GTC field by field and as whole files; reports which sex rule matches
This commit is contained in:
kemal committed 2026-09-28 00:10:47 +01:00
1 parent fcc1f6c3e3
commit 7032074bfe
3 files changed
+496

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Phase P4: genotype every Omni2.5 IDAT pair and compare the GTC with the vendor's (fields + whole file).
# Args: <data dir with bpm/egt and data/> <results dir>
set -uo pipefail
D=$1; OUT=$2
SRC=s3://1000genomes/release/20130502/supporting/hd_genotype_chip/broad_intensities
mkdir -p $D/egt
aws s3 cp --no-sign-request "$SRC/HumanOmni2.5-4v1-Multi_H.egt" $D/egt/ --only-show-errors
cp $D/HumanOmni2.5-4v1-Multi_B.egt $D/egt/
/usr/bin/time -v .venv/bin/python scripts/validate_p4_large.py --root $D/data --bpm $D/HumanOmni2.5-4v1-Multi_B.bpm \
--egt-dir $D/egt --out $OUT/summary.json --norm-version 1.1.2 --sex-version 1 --workers "$(nproc)" \
2> $OUT/time.txt || echo "P4 validation reported differences"
tail -25 $OUT/time.txt
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Phase P4 validation against vendor GTCs: genotype every IDAT pair and compare the produced GTC with the vendor's,
field by field and as whole files (after aligning free-text metadata that cannot match).
Usage: validate_p4_large.py --root DIR --bpm F --egt-dir DIR --out summary.json
[--norm-version 1.1.2] [--sex-version 1] [--workers N] [--limit N]
"""
from __future__ import annotations
import argparse
import json
import multiprocessing as mp
import os
import re
import sys
import time
import traceback
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
import numpy as np
NEG_NAN = np.frombuffer(np.uint32(0xFFC00000).tobytes(), dtype=np.float32)[0]
ALIGN = (
"cluster_file",
"snp_manifest",
"autocall_date",
"autocall_version",
"imaging_date",
"sample_name",
"sample_plate",
"sample_well",
"scanner_data",
)
_W: dict[str, Any] = {}
def _init(bpm: str, egt_dir: str, norm: str, sex_version: int) -> None:
_W.update(bpm=bpm, egt_dir=egt_dir, norm=norm, sex_version=sex_version, ctx={})
def _ctx(cluster_field: str) -> Any:
from simurg.formats.bpm import read_bpm
from simurg.formats.egt import read_egt
from simurg.genotype.clusters import align_clusters
from simurg.genotype.sample import manifest_context
name = re.sub(r"\(MD5Checksum=.*\)$", "", cluster_field).strip()
if name not in _W["ctx"]:
if "manifest" not in _W:
_W["manifest"] = read_bpm(_W["bpm"])
path = Path(_W["egt_dir"]) / name
if not path.exists():
raise FileNotFoundError(f"cluster file {name!r} named in the GTC is not in {_W['egt_dir']}")
m = _W["manifest"]
_W["ctx"][name] = manifest_context(m, align_clusters(read_egt(path), m), cluster_file_name=name)
return _W["ctx"][name]
def _canon(a: np.ndarray) -> np.ndarray: # type: ignore[type-arg]
a = np.asarray(a).copy()
if a.dtype.kind == "f":
a[np.isnan(a)] = NEG_NAN
return a
def check(args: tuple[str, str, str]) -> dict[str, Any]:
from simurg.formats.gtc import encode_gtc, read_gtc
from simurg.formats.idat import read_idat
from simurg.genotype.sample import GenotypeParams, genotype_sample
from simurg.genotype.sex import SexParams
gtc, grn, red = args
res: dict[str, Any] = {"gtc": gtc}
try:
ref = read_gtc(gtc, check_write_complete=False)
ctx = _ctx(ref.cluster_file)
params = GenotypeParams(
norm_version=_W["norm"], sex=SexParams(version=_W["sex_version"]), gtc_version=ref.version
)
g = genotype_sample(read_idat(grn), read_idat(red), ctx, params)
res["cluster_file"] = ctx.cluster_file_name
diffs: dict[str, Any] = {}
for k in ("raw_x", "raw_y", "genotypes", "genotype_scores", "base_calls", "controls_x", "controls_y"):
a, b = _canon(getattr(g, k)), _canon(getattr(ref, k))
if a.shape != b.shape:
diffs[k] = f"shape {a.shape} vs {b.shape}"
elif a.tobytes() != b.tobytes():
n = int((a.reshape(len(a), -1) != b.reshape(len(b), -1)).any(axis=1).sum())
diffs[k] = n
if g.normalization_transforms.tobytes() != ref.normalization_transforms.tobytes():
diffs["normalization_transforms"] = True
if ref.version >= 4:
for k in ("b_allele_freqs", "logr_ratios"):
if _canon(getattr(g, k)).tobytes() != _canon(getattr(ref, k)).tobytes():
diffs[k] = int(
(_canon(getattr(g, k)).view(np.uint32) != _canon(getattr(ref, k)).view(np.uint32)).sum()
)
for k in (
"call_rate",
"gender",
"logr_dev",
"gc10",
"gc50",
"num_calls",
"num_no_calls",
"num_intensity_only",
"ploidy",
"ploidy_type",
"dx",
"slide_identifier",
):
a, b = getattr(g, k), getattr(ref, k)
same = a == b or (isinstance(a, float) and np.isnan(a) and np.isnan(b))
if not same:
diffs[k] = [repr(a), repr(b)]
if "genotype_scores" in diffs:
d = np.abs(g.genotype_scores.astype(np.float64) - ref.genotype_scores.astype(np.float64))
res["max_score_abs_diff"] = float(np.nanmax(d))
res["field_diffs"] = diffs
# also evaluate the other sex-estimate generation on the same calls (cheap), to identify the vendor's rule
from simurg.genotype.sex import estimate_sex
from simurg.signal.normalization.apply import normalize, polar
nx, ny = normalize(g.raw_x, g.raw_y, ctx.lookup, g.normalization_transforms)
_, r_all = polar(nx, ny)
res["sex_by_version"] = {
v: estimate_sex(
g.genotypes, r_all, ctx.clusters.gentrain, ctx.clusters.present, ctx.sex_classes, SexParams(version=v)
)
for v in (1, 2)
}
res["sex_ref"] = ref.gender_char
for k in ALIGN:
setattr(g, k, getattr(ref, k))
for k in ("b_allele_freqs", "logr_ratios", "genotype_scores"):
setattr(g, k, _canon(getattr(g, k)))
setattr(ref, k, _canon(getattr(ref, k)))
res["byte_identical"] = encode_gtc(g) == encode_gtc(ref)
res["ref_layout_canonical"] = encode_gtc(ref) == Path(gtc).read_bytes() or None
res["metadata"] = {k: str(getattr(ref, k))[:80] for k in ("autocall_version", "cluster_file", "imaging_date")}
except Exception as e:
res["error"] = f"{type(e).__name__}: {e}"
res["trace"] = traceback.format_exc(limit=4)
return res
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", required=True, type=Path)
ap.add_argument("--bpm", required=True, type=Path)
ap.add_argument("--egt-dir", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--norm-version", default="1.1.2")
ap.add_argument("--sex-version", type=int, default=1)
ap.add_argument("--workers", type=int, default=os.cpu_count() or 1)
ap.add_argument("--limit", type=int, default=0)
a = ap.parse_args()
from simurg.formats.discovery import walk_files
t0 = time.time()
files = walk_files(a.root, (".gtc", ".idat"))
key_re = re.compile(r"(\d+_r\d{2}c\d{2})", re.IGNORECASE)
grn, red, gtcs = {}, {}, []
for p in files:
low = p.name.lower()
m = key_re.search(p.name)
if not m:
continue
k = m.group(1).lower()
if low.endswith("_grn.idat"):
grn[k] = str(p)
elif low.endswith("_red.idat"):
red[k] = str(p)
elif low.endswith(".gtc"):
gtcs.append((k, str(p)))
triples = [(g, grn[k], red[k]) for k, g in gtcs if k in grn and k in red]
if a.limit:
triples = triples[: a.limit]
with ProcessPoolExecutor(
a.workers,
mp_context=mp.get_context("spawn"),
initializer=_init,
initargs=(str(a.bpm), str(a.egt_dir), a.norm_version, a.sex_version),
) as ex:
results = list(ex.map(check, triples, chunksize=2))
field_counts: dict[str, int] = {}
for r in results:
for k in r.get("field_diffs", {}):
field_counts[k] = field_counts.get(k, 0) + 1
summary: dict[str, Any] = {
"n_gtc": len(gtcs),
"n_compared": len(results),
"errors": [r for r in results if "error" in r][:20],
"byte_identical": sum(bool(r.get("byte_identical")) for r in results),
"samples_with_no_field_diff": sum(not r.get("field_diffs") and "error" not in r for r in results),
"samples_with_field_diff": field_counts,
"genotype_diff_loci_total": sum(
int(r.get("field_diffs", {}).get("genotypes", 0) or 0)
if isinstance(r.get("field_diffs", {}).get("genotypes", 0), int)
else 0
for r in results
),
"score_diff_loci_total": sum(
int(r.get("field_diffs", {}).get("genotype_scores", 0) or 0)
if isinstance(r.get("field_diffs", {}).get("genotype_scores", 0), int)
else 0
for r in results
),
"max_score_abs_diff": max((r.get("max_score_abs_diff", 0.0) for r in results), default=0.0),
"cluster_files": sorted({r.get("cluster_file", "") for r in results}),
"sex_agreement_by_version": {
str(v): sum(r.get("sex_by_version", {}).get(v) == r.get("sex_ref") for r in results) for v in (1, 2)
},
"ref_sex_counts": {s: sum(r.get("sex_ref") == s for r in results) for s in ("M", "F", "U")},
"examples": [r for r in results if r.get("field_diffs")][:15],
"params": {"norm_version": a.norm_version, "sex_version": a.sex_version},
"seconds": round(time.time() - t0, 1),
}
summary["passed"] = summary["byte_identical"] == len(results) > 0
a.out.parent.mkdir(parents=True, exist_ok=True)
a.out.write_text(json.dumps(summary, indent=2, default=str) + "\n")
print(
json.dumps(
{
k: summary[k]
for k in (
"n_compared",
"byte_identical",
"samples_with_no_field_diff",
"samples_with_field_diff",
"genotype_diff_loci_total",
"score_diff_loci_total",
"passed",
"seconds",
)
}
)
)
return 0 if summary["passed"] else 1
if __name__ == "__main__":
sys.exit(main())
+235
View File
@@ -0,0 +1,235 @@
"""Phase P4: calling, BAF/LRR, sex, base calls and `simurg genotype` on synthetic data (plan §7.5–§7.8)."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from typer.testing import CliRunner
from simurg.formats.bpm import read_bpm
from simurg.formats.egt import read_egt
from simurg.formats.gtc import read_gtc
from simurg.genotype.baf_lrr import NEG_NAN, autosomal_mask, baf_lrr, logr_dev
from simurg.genotype.clusters import ClusterArrays, align_clusters
from simurg.genotype.gencall import call_genotypes, call_rate, smf, zmf
from simurg.genotype.sample import base_calls, invariant_datetime, top_alleles
from simurg.genotype.sex import SexParams, chromosome_classes, estimate_sex
from simurg.synth.files import SynthIdat, SynthLocus, encode_bpm, encode_egt, encode_idat
F32 = np.float32
def clusters(n: int, *, gentrain: float = 0.8, threshold: float = np.nan) -> ClusterArrays:
tm = np.tile(np.array([0.05, 0.5, 0.95], np.float32), (n, 1))
rm = np.ones((n, 3), np.float32)
return ClusterArrays(
present=np.ones(n, bool),
n=np.full((n, 3), 100, np.int32),
theta_mean=tm,
theta_dev=np.full((n, 3), 0.03, np.float32),
r_mean=rm,
r_dev=np.full((n, 3), 0.1, np.float32),
intensity_threshold=np.full(n, threshold, np.float32),
gentrain=np.full(n, gentrain, np.float32),
mean_r=np.ones(n, np.float32),
)
def test_fuzzy_memberships() -> None:
x = np.array([-1, 0, 0.25, 0.5, 0.75, 1, 2, np.nan], np.float32)
s = smf(x, F32(0), F32(1))
assert s.tolist()[:7] == [0.0, 0.0, 0.125, 0.5, 0.875, 1.0, 1.0]
assert s[7] == 1.0 # the reference returns 1 for NaN (all comparisons false)
z = zmf(x, F32(0), F32(1))
assert z.tolist()[:7] == [1.0, 1.0, 0.875, 0.5, 0.125, 0.0, 0.0] and z[7] == 0.0
assert smf(np.array([0.4, 0.6], np.float32), F32(1), F32(0)).tolist() == [0.0, 1.0] # a >= b rule
def test_calls_at_cluster_centres() -> None:
n = 6
t = np.array([0.05, 0.5, 0.95, 0.5, 0.5, 0.5], np.float32)
r = np.ones(n, np.float32)
io = np.array([0, 0, 0, 1, 0, 0], bool)
raw_zero = np.array([0, 0, 0, 0, 1, 0], bool)
c = clusters(n)
c.intensity_threshold[5] = 2.0 # R below the cluster file's intensity threshold -> no-call
calls = call_genotypes(t, r, raw_zero, c, io)
assert calls.genotypes.tolist() == [1, 2, 3, 0, 0, 0]
assert (calls.scores[:3] > 0.5).all() and calls.scores[4] == 0 and calls.scores[5] == 0
assert calls.num_calls == 3 and calls.num_intensity_only == 1
assert call_rate(calls, n) == F32(3 / 5)
def test_low_gentrain_and_outliers_score_low() -> None:
n = 2
t = np.array([0.5, 0.3], np.float32) # second point between clusters
calls_good = call_genotypes(t, np.ones(n, np.float32), np.zeros(n, bool), clusters(n), np.zeros(n, bool))
calls_bad = call_genotypes(
t, np.ones(n, np.float32), np.zeros(n, bool), clusters(n, gentrain=0.05), np.zeros(n, bool)
)
assert calls_good.scores[0] > calls_good.scores[1]
assert (calls_bad.scores < 0.15).all() and (calls_bad.genotypes == 0).all()
def test_baf_lrr_interpolation() -> None:
n = 6
t = np.array([0.05, 0.5, 0.95, 0.275, 1.2, np.nan], np.float32)
r = np.array([1, 1, 2, 1, 1, np.nan], np.float32)
c = clusters(n)
io = np.array([0, 0, 0, 0, 0, 0], bool)
baf, lrr = baf_lrr(t, r, np.zeros(n, bool), c, io)
assert baf[:5].tolist() == pytest.approx([0.0, 0.5, 1.0, 0.25, 1.0]) # clamped at 1
assert lrr[:4].tolist() == pytest.approx([0.0, 0.0, 1.0, 0.0])
assert baf.view(np.uint32)[5] == 0xFFC00000 and lrr.view(np.uint32)[5] == 0xFFC00000
io[1] = True
c.mean_r[1] = 2.0
_, lrr2 = baf_lrr(t, r, np.zeros(n, bool), c, io)
assert lrr2[1] == pytest.approx(-1.0) # intensity-only loci use the count-weighted mean R
assert np.isnan(NEG_NAN)
def test_logr_dev_locus_set() -> None:
lrr = np.array([1, -1, 1, -1, 50, 50, np.nan], np.float32)
chroms = ["1", "2", "0", "22_KI270879v1_alt", "X", "MT", "3"]
auto = autosomal_mask(chroms)
assert auto.tolist() == [True, True, True, True, False, False, True]
assert logr_dev(lrr, auto, np.zeros(7, bool), np.ones(7, bool)) == pytest.approx(1.0)
def test_sex_rules() -> None:
chroms = ["X"] * 30 + ["Y"] * 25 + ["1"] * 150
classes = chromosome_classes(chroms)
n = len(chroms)
gt = np.ones(n, np.uint8)
present = np.ones(n, bool)
gtrain = np.full(n, 0.8, np.float32)
r = np.ones(n, np.float32)
assert estimate_sex(gt, r, gtrain, present, classes) == "M" # median Y R 1.0 > 0.3
r_f = r.copy()
r_f[30:55] = 0.05
assert estimate_sex(gt, r_f, gtrain, present, classes) == "F" # X R 1.0 > 0.9
r_u = r_f.copy()
r_u[:30] = 0.5
assert estimate_sex(gt, r_u, gtrain, present, classes) == "U"
het = gt.copy()
het[:10] = 2 # X het rate 10/30 > 0.1
assert estimate_sex(het, r, gtrain, present, classes, SexParams(version=1)) == "F"
assert estimate_sex(gt, r, gtrain, present, classes, SexParams(version=1)) == "M"
def test_base_calls_and_top_strand(tmp_path: Path) -> None:
loci = [
SynthLocus("a", snp="[A/G]", ilmn_strand="TOP"),
SynthLocus("b", address_a=2, snp="[T/C]", ilmn_strand="BOT"),
SynthLocus("c", address_a=3, snp="[N/A]", ilmn_strand="BOT"),
]
(tmp_path / "m.bpm").write_bytes(encode_bpm(loci))
a, b = top_alleles(read_bpm(tmp_path / "m.bpm"))
assert bytes(a) == b"AA\x00" and bytes(b) == b"GGT"
bc = base_calls(np.array([1, 2, 0], np.uint8), a, b)
assert [bytes(x) for x in bc] == [b"AA", b"AG", b"--"]
def test_invariant_datetime() -> None:
import datetime as dt
assert invariant_datetime(dt.datetime(2026, 1, 2, 0, 5, 9)) == "1/2/2026 12:05:09 AM"
assert invariant_datetime(dt.datetime(2026, 12, 31, 13, 0, 0)) == "12/31/2026 1:00:00 PM"
def _synthetic_run(tmp_path: Path) -> dict[str, Path]:
"""Two samples on a 600-locus synthetic array with a matching cluster file."""
rng = np.random.default_rng(4)
n = 600
chroms = ["1"] * 500 + ["X"] * 60 + ["Y"] * 40
loci = [SynthLocus(f"rs{i}", chrom=chroms[i], address_a=i + 1, pos=i + 1) for i in range(n)]
loci.append(SynthLocus("TGA_Ctrl_5716", address_a=n + 1))
(tmp_path / "m.bpm").write_bytes(encode_bpm(loci))
names = [lc.name for lc in loci]
tm = np.tile([0.03, 0.5, 0.97], (len(loci), 1))
rm = np.tile([1.0, 1.0, 1.0], (len(loci), 1))
(tmp_path / "c.egt").write_bytes(encode_egt(names, theta_mean=tm, r_mean=rm))
idats = tmp_path / "idats"
idats.mkdir()
addr = np.arange(1, len(loci) + 1, dtype=np.int32)
for pos in ("R01C01", "R02C01"):
g = rng.choice(3, len(loci))
inten = rng.uniform(0.6, 1.4, len(loci))
x = inten * np.select([g == 0, g == 1, g == 2], [1.0, 0.5, 0.02]) * 8000 + 300
y = inten * np.select([g == 0, g == 1, g == 2], [0.02, 0.5, 1.0]) * 9000 + 400
for ch, mean in (("Red", x), ("Grn", y)):
s = SynthIdat(
address_ids=addr,
mean=np.clip(mean, 0, 65535).astype(np.uint16),
n_beads=np.full(len(loci), 12, np.uint8),
barcode="200000000001",
position=pos,
)
(idats / f"200000000001_{pos}_{ch}.idat").write_bytes(encode_idat(s))
sheet = tmp_path / "s.csv"
sheet.write_text(
"[Data]\nSentrixBarcode_A,SentrixPosition_A,Sample_Name,Gender,Tissue\n"
"200000000001,R01C01,alpha,Male,blood\n200000000001,R02C01,beta,Female,saliva\n"
"200000000001,R09C01,ghost,Female,blood\n"
)
return {"bpm": tmp_path / "m.bpm", "egt": tmp_path / "c.egt", "idats": idats, "sheet": sheet}
def test_genotype_cli_end_to_end(tmp_path: Path) -> None:
from simurg.cli.main import app
f = _synthetic_run(tmp_path)
runner = CliRunner()
args = [
"genotype",
"--quiet",
"--bpm",
str(f["bpm"]),
"--egt",
str(f["egt"]),
"--idats",
str(f["idats"]),
"--sheet",
str(f["sheet"]),
"--autocall-date",
"1/1/2026 12:00:00 PM",
]
r1 = runner.invoke(app, [*args, "--out", str(tmp_path / "o1"), "--threads", "1"])
assert r1.exit_code == 3, r1.output # 'ghost' has no IDATs
r2 = runner.invoke(app, [*args, "--out", str(tmp_path / "o2"), "--threads", "2", "--layout", "flat"])
assert r2.exit_code == 3
for stem in ("alpha", "beta"):
a = (tmp_path / "o1" / "samples" / stem / f"{stem}.gtc").read_bytes()
assert a == (tmp_path / "o2" / f"{stem}.gtc").read_bytes() # thread count / layout never change bytes
g = read_gtc(tmp_path / "o1" / "samples" / stem / f"{stem}.gtc")
assert g.version == 5 and g.num_snps == 601 and g.autocall_version.startswith("SimurgArray ")
assert g.cluster_file == "c.egt" and g.snp_manifest == "m.bpm"
assert g.call_rate > 0.9
summary = json.loads((tmp_path / "o1" / "sample_summary.json").read_text())
assert summary["schema"] == "simurg.sample_summary/1"
cols = summary["columns"]
assert cols[:5] == ["sample_id", "sample_name", "sample_folder", "status", "fail_reason"]
assert "TGA_Ctrl_5716_norm_r" in cols and cols[-2:] == ["Gender", "Tissue"]
by = {s["sample_name"]: s for s in summary["samples"]}
assert by["ghost"]["status"] == "MISSING_INPUT" and by["alpha"]["status"] == "OK"
assert by["alpha"]["sex_reported"] == "M" and by["alpha"]["Tissue"] == "blood"
assert by["alpha"]["sex_check"] in ("match", "mismatch", "unknown")
tsv = (tmp_path / "o1" / "sample_summary.tsv").read_text().splitlines()
assert tsv[0].split("\t") == cols and len(tsv) == 4
r3 = runner.invoke(app, [*args, "--out", str(tmp_path / "o1")])
assert r3.exit_code == 2 and "E_OUTPUT_EXISTS" in r3.output
def test_cluster_alignment_errors(tmp_path: Path) -> None:
f = _synthetic_run(tmp_path)
(tmp_path / "small.egt").write_bytes(encode_egt(["rs1"], theta_mean=[[0.1, 0.5, 0.9]], r_mean=[[1, 1, 1]]))
from simurg.core.errors import SimurgError
with pytest.raises(SimurgError) as e:
align_clusters(read_egt(tmp_path / "small.egt"), read_bpm(f["bpm"]))
assert e.value.code == "E_EGT_MANIFEST_MISMATCH"
c = align_clusters(read_egt(tmp_path / "small.egt"), read_bpm(f["bpm"]), allow_missing=True)
assert int(c.present.sum()) == 1