Files
SimurgArray/scripts/validate_p4_large.py
T
kemal f469c97917 P4 validated on 2141 vendor GTCs; legacy AutoCall 1.6.2 preset
- vendor comparison (1000 Genomes Omni2.5, AutoCall 1.6.2.2): every
  computed field identical in 2138/2141 samples; the 3 others trace back
  to the single-locus raw-intensity rule (K-009); sex rule 1 matches all
- AutoCall 1.6.2.x leaves GC10/GC50/#calls/#no-calls at 0 in GTC v3:
  new `--preset autocall-1.6.2` (1.1.2 normalization, sex rule 1, GTC v3,
  legacy statistics); the sample summary keeps the real statistics
- NaN sign canonicalised to the x86 pattern (K-014); report P4.md
2026-09-28 01:21:49 +01:00

255 lines
10 KiB
Python

#!/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, preset: str) -> None:
_W.update(bpm=bpm, egt_dir=egt_dir, norm=norm, sex_version=sex_version, preset=preset, 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 genotype_sample, params_for_preset
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 = (
params_for_preset(_W["preset"], gtc_version=ref.version)
if _W["preset"]
else params_for_preset(
"current", 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",
):
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 ref.version >= 5 and g.slide_identifier != ref.slide_identifier: # field 1016 exists only in v5
diffs["slide_identifier"] = [g.slide_identifier, ref.slide_identifier]
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("--preset", default="autocall-1.6.2", help="genotype preset; '' = use --norm/--sex-version")
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, a.preset),
) 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": {"preset": a.preset, "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())