Files
SimurgArray/scripts/validate_p1_large.py
T
kemalandClaude Opus 5.5 150650424c
ci / test (3.12) (push) Failing after 10s
ci / test (3.13) (push) Failing after 12s
Lint fixes in validate_p1_large.py
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
2026-09-27 19:30:37 +01:00

226 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""Phase P1 validation on a large data set (e.g. 1000 Genomes Omni2.5, 2141 samples with vendor GTCs).
For every GTC: parse, compare every field with Illumina BeadArrayFiles (BSD-2) and test the byte-identical
round-trip. For every IDAT pair: parse, pair-check and compare the address set with the manifest. BPM/EGT/CSV are
compared with BeadArrayFiles once. Writes a JSON summary; exit code 0 only if everything matched.
Usage: validate_p1_large.py --root DIR --bpm F --egt F [--csv F] --out summary.json [--workers N] [--limit N]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import traceback
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "refs" / "BeadArrayFiles"))
def check_gtc(path: str) -> dict[str, Any]:
from module.GenotypeCalls import GenotypeCalls # type: ignore[import-not-found]
from simurg.formats.gtc import encode_gtc, read_gtc
res: dict[str, Any] = {"file": path, "ok": True, "diffs": []}
try:
g = read_gtc(path, check_write_complete=False)
raw = Path(path).read_bytes()
res["version"] = g.version
res["autocall_version"] = g.autocall_version
res["roundtrip_identical"] = encode_gtc(g) == raw
if not res["roundtrip_identical"]:
res["roundtrip_canonical_identical"] = encode_gtc(g, canonical=True) == raw
res["size_ours_vs_file"] = [len(encode_gtc(g)), len(raw)]
b = GenotypeCalls(path, check_write_complete=False)
pairs = {
"raw_x": (g.raw_x, b.get_raw_x_intensities()),
"raw_y": (g.raw_y, b.get_raw_y_intensities()),
"genotypes": (g.genotypes, np.array(b.get_genotypes(), dtype=np.uint8)),
"scores": (g.genotype_scores, b.get_genotype_scores()),
}
if g.version >= 4:
pairs["baf"] = (g.b_allele_freqs, b.get_ballele_freqs())
pairs["lrr"] = (g.logr_ratios, b.get_logr_ratios())
for k, (a, c) in pairs.items():
if not np.array_equal(np.asarray(a), np.asarray(c), equal_nan=a.dtype.kind == "f"):
res["diffs"].append(k)
scalars = {
"call_rate": (g.call_rate, b.get_call_rate()),
"gender": (g.gender, b.get_gender()),
"logr_dev": (g.logr_dev, b.get_logr_dev()),
"gc10": (g.gc10, b.get_gc10()),
"gc50": (g.gc50, b.get_gc50()),
"num_calls": (g.num_calls, b.get_num_calls()),
"sample_name": (g.sample_name, b.get_sample_name()),
"base_calls": (g.base_call_strings(), [x.decode() for x in b.get_base_calls()]),
}
for k, (a, c) in scalars.items():
same = (a == c) or (isinstance(a, float) and np.isnan(a) and np.isnan(c))
if not same:
res["diffs"].append(k)
xf_b = [
(t.offset_x, t.offset_y, t.scale_x, t.scale_y, t.shear, t.theta) for t in b.get_normalization_transforms()
]
xf_g = [
tuple(float(x[k]) for k in ("offset_x", "offset_y", "scale_x", "scale_y", "shear", "theta"))
for x in g.normalization_transforms
]
if [tuple(float(v) for v in t) for t in xf_b] != xf_g:
res["diffs"].append("normalization_transforms")
res["ok"] = not res["diffs"]
except Exception as e:
res.update(ok=False, error=f"{type(e).__name__}: {e}", trace=traceback.format_exc(limit=3))
return res
def check_idat_pair(args: tuple[str, str]) -> dict[str, Any]:
from simurg.formats.idat import check_pair, read_idat
grn, red = args
res: dict[str, Any] = {"grn": grn, "ok": True}
try:
g, r = read_idat(grn), read_idat(red)
check_pair(g, r)
res.update(
n=g.n_addresses,
chip=g.chip_type,
midblock_itemsize=g.mid_block_itemsize,
scan=g.scan_time_iso,
name_ok=(g.barcode in Path(grn).name),
)
except Exception as e:
res.update(ok=False, error=f"{type(e).__name__}: {e}")
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", required=True, type=Path)
ap.add_argument("--csv", type=Path)
ap.add_argument("--out", required=True, type=Path)
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.bpm import read_bpm
from simurg.formats.discovery import walk_files
from simurg.formats.egt import read_egt
t0 = time.time()
summary: dict[str, Any] = {"root": str(a.root), "started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
gtcs = [str(p) for p in walk_files(a.root, (".gtc",))]
idats = walk_files(a.root, (".idat", ".idat.gz"))
if a.limit:
gtcs = gtcs[: a.limit]
grn = {p.name.lower().replace("_grn.idat", ""): p for p in idats if "_grn.idat" in p.name.lower()}
red = {p.name.lower().replace("_red.idat", ""): p for p in idats if "_red.idat" in p.name.lower()}
pairs = [(str(grn[k]), str(red[k])) for k in sorted(set(grn) & set(red))]
if a.limit:
pairs = pairs[: a.limit]
summary["n_gtc"] = len(gtcs)
summary["n_idat_pairs"] = len(pairs)
summary["unpaired_idats"] = len(set(grn) ^ set(red))
with ProcessPoolExecutor(a.workers) as ex:
gres = list(ex.map(check_gtc, gtcs, chunksize=4))
ires = list(ex.map(check_idat_pair, pairs, chunksize=8))
summary["gtc_ok"] = sum(r["ok"] for r in gres)
summary["gtc_roundtrip_identical"] = sum(bool(r.get("roundtrip_identical")) for r in gres)
summary["gtc_versions"] = sorted({r.get("version") for r in gres if "version" in r})
summary["gtc_autocall_versions"] = sorted({r.get("autocall_version") for r in gres if "autocall_version" in r})
summary["gtc_failures"] = [r for r in gres if not r["ok"]][:50]
summary["gtc_roundtrip_failures"] = [r for r in gres if r.get("roundtrip_identical") is False][:20]
summary["idat_ok"] = sum(r["ok"] for r in ires)
summary["idat_failures"] = [r for r in ires if not r["ok"]][:50]
summary["idat_midblock_itemsizes"] = sorted({r.get("midblock_itemsize") for r in ires if r["ok"]})
summary["idat_chip_types"] = sorted({r.get("chip") for r in ires if r["ok"]})
# manifest / cluster file vs BeadArrayFiles
import module # type: ignore[import-not-found]
m = read_bpm(a.bpm)
b = module.BeadPoolManifest(str(a.bpm))
summary["bpm"] = {
"num_loci": m.num_loci,
"names": m.names == b.names,
"snps": m.snps == b.snps,
"chroms": m.chroms == b.chroms,
"map_infos": m.map_infos.tolist() == b.map_infos,
"address_a": m.address_a.tolist() == b.addresses,
"assay_types": m.assay_types.tolist() == b.assay_types,
"normalization_lookups": m.normalization_lookups().tolist() == b.normalization_lookups,
"n_bins": len(np.unique(m.norm_bin_ids())),
}
e = read_egt(a.egt)
with open(a.egt, "rb") as fh:
be = module.ClusterFile.read_cluster_file(fh)
bad = 0
def same(a: float, b: float) -> bool:
return bool(np.float32(a) == np.float32(b) or (np.isnan(a) and np.isnan(b)))
for i, name in enumerate(e.names):
rec = be.get_record(name)
st = (rec.aa_cluster_stats, rec.ab_cluster_stats, rec.bb_cluster_stats)
if not all(
same(e.theta_mean[i, k], s.theta_mean)
and same(e.r_mean[i, k], s.r_mean)
and same(e.theta_dev[i, k], s.theta_dev)
and same(e.r_dev[i, k], s.r_dev)
and e.n[i, k] == s.N
for k, s in enumerate(st)
):
bad += 1
summary["egt"] = {
"num_records": e.num_records,
"dbv": e.data_block_version,
"extra_floats": e.extra_per_record,
"record_mismatches": bad,
}
if a.csv:
from simurg.formats.csv_manifest import compare_manifests, read_csv_manifest
summary["bpm_csv_problems"] = compare_manifests(m, read_csv_manifest(a.csv, load_sequences=False))
if gtcs:
g0 = __import__("simurg.formats.gtc", fromlist=["read_gtc"]).read_gtc(gtcs[0], check_write_complete=False)
summary["gtc_vs_bpm_num_snps"] = [g0.num_snps, m.num_loci]
summary["gtc_transforms_vs_bins"] = [len(g0.normalization_transforms), summary["bpm"]["n_bins"]]
summary["seconds"] = round(time.time() - t0, 1)
passed = (
summary["gtc_ok"] == len(gtcs)
and summary["idat_ok"] == len(pairs)
and all(v for k, v in summary["bpm"].items() if isinstance(v, bool))
and summary["egt"]["record_mismatches"] == 0
and not summary.get("bpm_csv_problems")
)
summary["passed"] = passed
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_gtc", "gtc_ok", "gtc_roundtrip_identical", "n_idat_pairs", "idat_ok", "passed", "seconds")
}
)
)
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())