P5a: QC — control metrics, thresholds, status, contamination, relatedness, locus summary
ci / test (3.12) (push) Failing after 8s
ci / test (3.13) (push) Failing after 11s

- qc/controls: canonical control names (QC-002), bkg/offset (QC-003), 13 genotyping metrics (QC-004/005),
  missing/zero-divisor handling (QC-007/008); controls_raw.tsv / controls_qc.tsv (QC-009/011) incl. compat CSVs
- qc/thresholds + profiles: YAML schema, validation codes, precedence, shipped profiles, auto (QC-020..025)
- qc/status: functional PASS/FAIL/NA, control CLEAR/FLAGGED, flags (QC-030..033)
- qc/contamination: BAF regression; bit-compatible with gtc2vcf BAFregress (36/36, K-017), exact mode too
- qc/relatedness: KING-robust, duplicates, Mendelian errors, pedigree from sheet (IN-018)
- qc/locus_summary: OUT-072, bit-identical to BeadArrayFiles except ChiTest (K-018)
- qc/suggest: suggest-thresholds (QC-026)
- pipeline/qc: disk-backed cohort store; CLI qc controls|samples|contamination|relatedness|loci|suggest-thresholds
- genotype: control tables (OUT-070), het_rate, QC columns, cohort QC
- tests: unit + oracle (BAFregress plugin, BeadArrayFiles locus summary)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
This commit is contained in:
kemalandClaude Opus 5.5 committed 2026-09-28 08:35:10 +01:00
1 parent 8b1e5d8dab
commit 0c004f6398
28 files changed
+3355 -22

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
"""Per-locus summary statistics across samples (spec OUT-072, CLI-025; plan §7.9).
[OSS: BeadArrayFiles ``examples/locus_summary.py`` (BSD-2) — formulas: call/genotype frequencies, minor frequency,
k-regularised heterozygote excess with k = (0.013/q)^4, ChiTest_P100 = 2·Φ(±dh; 0, 0.1), GenomeStudio percentile of
the called GenCall scores.] Vectorised over a block of loci; the caller streams blocks for large cohorts.
"""
from __future__ import annotations
import math
import numpy as np
import numpy.typing as npt
COLUMNS = [
"locus_name",
"locus_id",
"n_nocalls",
"n_calls",
"call_freq",
"aa_freq",
"ab_freq",
"bb_freq",
"minor_freq",
"gentrain_score",
"gc50",
"gc10",
"het_excess",
"chi_test_p100",
"cluster_sep",
"aa_t_mean",
"aa_t_sd",
"ab_t_mean",
"ab_t_sd",
"bb_t_mean",
"bb_t_sd",
"aa_r_mean",
"aa_r_sd",
"ab_r_mean",
"ab_r_sd",
"bb_r_mean",
"bb_r_sd",
"plus_minus_strand",
"het_excess_simple",
]
HET_EXCESS_EPS = 1e-9 # OQ-030: denominator guard of the simple form
M_SQRT1_2 = 0.70710678118654752440 # C literal (1/sqrt(2) in Python is 1 ULP lower)
def _norm_cdf_sd01(x: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
"""Φ(x; 0, 0.1) with the branch structure of the cephes ``ndtr`` used by the reference."""
z = (x / 0.1) * M_SQRT1_2
out = np.empty_like(z)
for i, v in enumerate(z.tolist()):
a = abs(v)
if a < M_SQRT1_2:
y = 0.5 + 0.5 * math.erf(v)
else:
y = 0.5 * math.erfc(a)
if v > 0:
y = 1.0 - y
out[i] = y
return out
def gs_percentile_columns(
sorted_scores: npt.NDArray[np.float32], counts: npt.NDArray[np.int64], p: int
) -> npt.NDArray[np.float64]:
"""GenomeStudio percentile of the first ``counts[j]`` values of each column of ``sorted_scores`` (double math)."""
n = counts.astype(np.int64)
pos = n * p / 100.0
idx = pos.astype(np.int64) # int(num*p/100) truncates like the reference
idx = np.where(pos - idx < 0.5, idx - 1, idx)
cols = np.arange(sorted_scores.shape[1])
last = np.maximum(n - 1, 0)
lo = np.clip(idx, 0, np.maximum(last - 1, 0))
y1 = sorted_scores[lo, cols].astype(np.float64)
y2 = sorted_scores[np.minimum(lo + 1, last), cols].astype(np.float64)
with np.errstate(invalid="ignore", divide="ignore"):
x1 = 100 * (lo + 0.5) / n
x2 = 100 * (lo + 1 + 0.5) / n
mid = y1 + (y2 - y1) / (x2 - x1) * (p - x1)
first = sorted_scores[0, cols].astype(np.float64)
lastv = sorted_scores[last, cols].astype(np.float64)
out = np.where(idx < 0, first, np.where(idx >= n - 1, lastv, mid))
return np.where(n == 0, np.nan, out)
def summarize_block(genotypes: npt.NDArray[np.uint8], scores: npt.NDArray[np.float32]) -> dict[str, npt.NDArray]: # type: ignore[type-arg]
"""Statistics for a block of loci; inputs are (samples × loci)."""
g = np.asarray(genotypes)
nc = (g == 0).sum(axis=0).astype(np.int64)
aa = (g == 1).sum(axis=0).astype(np.int64)
ab = (g == 2).sum(axis=0).astype(np.int64)
bb = (g == 3).sum(axis=0).astype(np.int64)
calls = aa + ab + bb
with np.errstate(invalid="ignore", divide="ignore"):
call_freq = np.where(calls + nc > 0, calls / (calls + nc), np.nan)
cf = calls.astype(np.float64)
aa_f = np.where(calls > 0, aa / cf, np.nan)
ab_f = np.where(calls > 0, ab / cf, np.nan)
bb_f = np.where(calls > 0, bb / cf, np.nan)
a_freq = np.where(calls > 0, (aa * 2 + ab) / (2 * cf), np.nan)
q = np.minimum(a_freq, 1.0 - a_freq)
p = 1 - q
temp = 0.013 / q
k = temp * temp * temp * temp
dh = ((ab / cf + k) / (2 * p * q + k)) - 1
simple = (ab_f - 2 * p * q) / (2 * p * q + HET_EXCESS_EPS)
mono = (aa + ab == 0) | (ab + bb == 0)
regular = (calls > 0) & ~mono
hw = np.zeros(len(calls))
het = np.zeros(len(calls))
if regular.any():
d = dh[regular]
cdf = _norm_cdf_sd01(d)
hw[regular] = np.where(d < 0, 2 * cdf, 2 * (1 - cdf))
het[regular] = d
hw[(calls > 0) & mono] = 1.0
called_scores = np.where(g != 0, scores.astype(np.float32), np.float32(np.inf))
called_scores.sort(axis=0)
return {
"n_nocalls": nc,
"n_calls": calls,
"call_freq": call_freq,
"aa_freq": aa_f,
"ab_freq": ab_f,
"bb_freq": bb_f,
"minor_freq": q,
"gc50": gs_percentile_columns(called_scores, calls, 50),
"gc10": gs_percentile_columns(called_scores, calls, 10),
"het_excess": het,
"chi_test_p100": hw,
"het_excess_simple": np.where(calls > 0, simple, np.nan),
}