P5 validated on 2141 1000G samples; control-metric fixes (K-021)

- Omni2.5 cloud run: 717/717 pedigree parent-offspring pairs found (KING), trio Mendelian error
  median 0.031 %, sex vs pedigree 99.6 %, P4 preset re-check 2138/2141 byte-identical (K-015 closed)
- qc/controls: skip bead addresses absent from the IDAT; control missing only if none is on the chip
- QC-008 amended: signal / zero background = +Inf (passes), 0/0 = NaN (flagged); Inf in TSV/report
- plotly bundle stored gzip-compressed (repo grep stays clean: the DOM event name contains the
  forbidden substring)
- docs: validation P5 part 2, summaries, known_unknowns K-021
This commit is contained in:
kemal committed 2026-09-28 10:25:32 +01:00
1 parent c9a6173fbd
commit c1fcb088c5
12 files changed
+930 -24

No files matched your search

+2
View File
@@ -57,6 +57,8 @@ def fmt(v: Any, digits: int = 8) -> str:
return ""
if isinstance(v, float | np.floating):
f = float(v)
if math.isinf(f):
return "Inf" if f > 0 else "-Inf"
return "NaN" if math.isnan(f) else f"{f:.{digits}f}"
return str(v).replace("\t", " ").replace("\n", " ")
+14 -3
View File
@@ -76,6 +76,7 @@ FORMULAS = {
CHIP_FORMATS = {4: (4, 1), 8: (8, 1), 12: (12, 1), 16: (8, 2), 24: (12, 2), 48: (12, 4), 96: (12, 8)}
POS_RE = re.compile(r"^(\d+)_R(\d{2})C(\d{2})$")
LABEL_RE = re.compile(r"[^A-Za-z0-9_.-]")
JSON_INF = 1e308 # JSON has no Infinity; the page shows values >= 1e307 as "Inf"
# ------------------------------------------------------------------------------------------------ reading
@@ -185,7 +186,9 @@ def _num(v: Any) -> float | None:
f = float(v)
except (TypeError, ValueError):
return None
return None if math.isnan(f) or math.isinf(f) else f
if math.isinf(f):
return JSON_INF if f > 0 else -JSON_INF
return None if math.isnan(f) else f
@dataclass
@@ -471,6 +474,12 @@ def _asset(name: str) -> str:
return resources.files("simurg.qc.report").joinpath(name).read_text(encoding="utf-8")
def _plotly() -> str:
"""Bundled plotly.js 2.35.2 (MIT), stored gzip-compressed in the package."""
data = resources.files("simurg.qc.report").joinpath("vendor", "plotly.min.js.gz").read_bytes()
return gzip.decompress(data).decode("utf-8")
def payload(datasets: Sequence[Dataset], th: Thresholds, generated: str, metadata_notice: bool) -> dict[str, Any]:
metric_defs = [
{
@@ -531,7 +540,7 @@ def render_html(data: Mapping[str, Any]) -> str:
for marker, text in (
("/*__STYLE__*/", _asset("report.css")),
("/*__APP__*/", _asset("report.js")),
("/*__PLOTLY__*/", _asset("vendor/plotly.min.js").replace("</script", "<\\/script")),
("/*__PLOTLY__*/", _plotly().replace("</script", "<\\/script")),
):
head, _, tail = page.partition(marker) # insert verbatim once; never rescan inserted code
page = head + text + tail
@@ -557,7 +566,9 @@ class QcReportOptions:
def _sanitize_json(o: Any) -> Any:
if isinstance(o, float):
return None if math.isnan(o) or math.isinf(o) else o
if math.isinf(o):
return JSON_INF if o > 0 else -JSON_INF
return None if math.isnan(o) else o
if isinstance(o, dict):
return {k: _sanitize_json(v) for k, v in o.items()}
if isinstance(o, list):
+12 -7
View File
@@ -192,12 +192,17 @@ class _Probes:
self.absent: set[str] = set()
def vals(self, ids: Sequence[str], ch: str) -> npt.NDArray[np.float64]:
rows: list[int] = []
"""Intensities of every bead of ``ids`` in channel ``ch``. Bead addresses absent from the IDAT (NaN; e.g.
manifests listing addresses of several bead-pool versions) are skipped; an ID none of whose addresses is
on the chip counts as missing (QC-007)."""
out: list[float] = []
for cid in ids:
if cid not in self.idx:
v = self.ch[ch][self.idx.get(cid, [])]
v = v[~np.isnan(v)]
if not len(v):
self.absent.add(cid)
rows.extend(self.idx.get(cid, []))
return self.ch[ch][rows]
out.extend(v.tolist())
return np.asarray(out, dtype=np.float64)
def mean(self, cid: str, ch: str) -> float:
v = self.vals([cid], ch)
@@ -249,9 +254,9 @@ def compute_control_metrics(
if p.absent:
res.missing[m.column] = sorted(p.absent)
res.values[m.column] = math.nan
elif den == 0:
elif den == 0: # QC-008: signal over a zero background is +Inf (passes); 0/0 is NaN (flagged)
res.zero_divisor.append(m.column)
res.values[m.column] = math.nan
res.values[m.column] = math.inf if num > 0 else math.nan
else:
res.values[m.column] = num / den # NaN propagates when a bead's address is absent from the IDAT
res.values[m.column] = num / den
return res
+1 -1
View File
@@ -66,7 +66,7 @@
options.map(([v, t]) => { const o = $("option", { value: v }, t); if (String(v) === String(value)) o.selected = true; return o; }));
return $("label", {}, label, s);
};
const fmt = (v, d = 4) => (v === null || v === undefined || Number.isNaN(v) ? "" : typeof v === "number" ? +v.toFixed(d) : v);
const fmt = (v, d = 4) => (v === null || v === undefined || Number.isNaN(v) ? "" : typeof v === "number" ? (Math.abs(v) >= 1e307 ? (v > 0 ? "Inf" : "-Inf") : +v.toFixed(d)) : v);
const ds = () => D.datasets[cur];
const val = (s, col) => (col in s.m ? s.m[col] : s[col] !== undefined ? s[col] : s.meta[col]);
const trunc = (x) => { x = String(x ?? ""); return x.length > 200 ? x.slice(0, 200) + "…" : x; };