v2 methylation control QC (WIP): simurg methyl qc|convert-manifest (MET-001..018)

Methylation CSV manifest reader (MET-002/003), raw M/U assembly with bead masking
(MET-005), pOOBAH detection p-values, noob background correction, linear/nonlinear dye
bias, beta and M-values (MET-010..012), the 21 methylation control metrics (MET-006,
bs-conv red-bg set option), per-sample ctrl/cgs tables, summary tsv/xlsx/html with the
detection histogram and a beta PCA, pcs.tsv.gz, controls_raw/controls_qc.tsv; methylation
datasets load in qc report (MET-014); qc controls --array-type methylation; minfi-style
Sentrix_ID/Sentrix_Position sheet keys. Vendor EPIC manifests carry a 5th field on some
control lines: >= 4 fields accepted. Runs on the public minfiDataEPIC set (3 x NA12878).
This commit is contained in:
kemal committed 2026-09-29 00:13:22 +01:00
1 parent 26c8a0cf40
commit 8758178df6
13 files changed
+1292 -21

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
"""``simurg methyl qc`` / ``simurg methyl convert-manifest`` — methylation control QC (spec MET-001 … MET-018).
Per sample (streamed, worker processes): control table, detection p-values, noob + dye-bias corrected β and M-values.
Per batch: ``methyl_qc_summary.tsv/.xlsx/.html``, ``pcs.tsv.gz``, ``probes.tsv``/``controls.tsv`` and the shared
``controls_raw.tsv``/``controls_qc.tsv`` (so ``simurg qc report`` accepts the folder).
"""
from __future__ import annotations
import datetime as _dt
import gzip
import html
import json
import math
import multiprocessing as mp
import os
import pickle
import tempfile
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import numpy.typing as npt
from simurg.core.atomic import check_outputs_absent
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
from simurg.core.provenance import RunManifest
from simurg.formats.idat import check_pair, read_idat
from simurg.formats.methyl_manifest import MethylManifest, read_methyl_manifest, write_probe_tables
from simurg.pipeline.qc import ControlSample, idat_metadata, write_control_tables, write_tsv
from simurg.qc.controls import ControlBead, canonical_id, compute_control_metrics, metrics_for
from simurg.qc.thresholds import Thresholds, parse_cli_pairs, resolve_thresholds
from simurg.signal import methyl as sig
SUMMARY = "methyl_qc_summary"
CONTROL_TYPES = ("STAINING", "EXTENSION", "HYBRIDIZATION", "TARGET REMOVAL", "NON-POLYMORPHIC", "SPECIFICITY I",
"SPECIFICITY II", "BISULFITE CONVERSION I", "BISULFITE CONVERSION II", "NEGATIVE", "NORM_A",
"NORM_C", "NORM_G", "NORM_T") # fmt: skip
AVG_COLUMNS = ("avg_green_raw", "avg_red_raw", "avg_green_norm", "avg_red_norm")
@dataclass
class MethylQcOptions:
out: Path
manifest: Path
idats: Path | None = None
sheet: Path | None = None
config: Path | None = None
profile: str | None = "auto"
thresholds: list[str] = field(default_factory=list)
bg_offset: float | None = None
bs_conv_red_bg_set: str = "U3-U5"
pval_threshold: float = 0.05
beta_offset: float = 0.0
dye_bias: str = "linear"
min_prop_passing: float | None = None
pca_max_samples_full: int = 100
pca_probes: int = 10_000
seed: int = 42
threads: int = 0
force: bool = False
on_duplicate_idat: str = "error"
argv: list[str] = field(default_factory=list)
@dataclass
class _Job:
sample_id: str
grn: str | None
red: str | None
sheet_values: dict[str, str]
out_dir: str
_W: dict[str, Any] = {}
def _init_from_file(path: str) -> None:
with open(path, "rb") as fh:
_W.update(pickle.load(fh))
def _write_gz_tsv(path: Path, header: str, lines: list[str]) -> None:
tmp = path.with_suffix(path.suffix + ".part")
with gzip.open(tmp, "wt", encoding="utf-8", newline="\n", compresslevel=6) as fh:
fh.write(header + "\n")
fh.write("\n".join(lines))
if lines:
fh.write("\n")
tmp.replace(path)
def _g6(v: float) -> str:
return "NA" if not math.isfinite(v) else f"{v:.6g}"
def _process(job: _Job) -> dict[str, Any]:
man: MethylManifest = _W["man"]
opts: MethylQcOptions = _W["opts"]
beads: list[ControlBead] = _W["beads"]
row: dict[str, Any] = {"sample_id": job.sample_id, "status": "OK", "fail_reason": ""}
if not job.grn or not job.red:
row.update(status="FAILED", fail_reason="W_METH_MISSING_SAMPLE: IDATs absent")
return row
step = "read"
try:
grn, red = read_idat(job.grn), read_idat(job.red)
check_pair(grn, red)
row["meta"] = idat_metadata(grn, red, job.sheet_values)
row["sentrix_id"], row["sentrix_position"] = grn.barcode or red.barcode, grn.position or red.position
row["scan_time"] = red.scan_time_iso or grn.scan_time_iso
step = "signal assembly"
raw = sig.assemble(grn, red, man)
g, r = raw.ctrl_green, raw.ctrl_red
qc = compute_control_metrics(beads, g, r, "methylation", _W["bg_offset"], opts.bs_conv_red_bg_set)
row["control"] = ControlSample(job.sample_id, row["meta"], g, r, qc)
step = "detection"
pval = sig.detection_p(raw)
step = "noob"
corr, _, _ = sig.noob(raw)
step = "dye bias"
corr = sig.dye_bias_nonlinear(corr) if opts.dye_bias == "nonlinear" else sig.dye_bias_linear(corr)
step = "beta"
beta = sig.beta_values(corr, opts.beta_offset)
mval = sig.m_values(beta)
ana = _W["analytical"]
row["n_probes"] = int(ana.sum())
row["n_pass_detection"] = int((pval[ana] < opts.pval_threshold).sum())
row["prop_probes_passing"] = row["n_pass_detection"] / row["n_probes"] if row["n_probes"] else math.nan
row["avg_green_raw"], row["avg_red_raw"] = sig.average_signals(raw, ana)
row["avg_green_norm"], row["avg_red_norm"] = sig.average_signals(corr, ana)
row["pca"] = beta[_W["pca_idx"]].astype(np.float32)
step = "write"
d = Path(job.out_dir)
d.mkdir(parents=True, exist_ok=True)
_write_gz_tsv(
d / f"{job.sample_id}_ctrl.tsv.gz",
"address\ttype\tcolor_channel\tname\tprobe_id\tgreen\tred",
[
f"{c.address}\t{c.type}\t{c.color_channel}\t{c.name}\t{c.probe_id}\t{_g6(gv)}\t{_g6(rv)}"
for c, gv, rv in zip(man.controls, g.tolist(), r.tolist(), strict=True)
],
)
_write_gz_tsv(
d / f"{job.sample_id}_cgs.tsv.gz",
"probe_id\tbeta\tm_value\tdetection_p",
[
f"{pid}\t{_g6(b)}\t{_g6(mv)}\t{_g6(pv)}"
for pid, b, mv, pv in zip(man.probe_ids, beta.tolist(), mval.tolist(), pval.tolist(), strict=True)
],
)
except Exception as e: # MET-010: this sample fails, the others continue
code = e.code if isinstance(e, SimurgError) else "E_METH_PREP_FAILED"
row.update(status="FAILED", fail_reason=f"{code}: Prep failed ({step}): {e}")
row.pop("control", None)
return row
def _pca(mat: npt.NDArray[np.float32]) -> npt.NDArray[np.float64] | None:
"""First two principal components (samples × 2) of a samples × probes matrix; None when not computable."""
ok = np.isfinite(mat).all(axis=0)
x = mat[:, ok].astype(np.float64)
if x.shape[0] < 2 or x.shape[1] < 2:
return None
x -= x.mean(axis=0)
gram = x @ x.T
w, v = np.linalg.eigh(gram)
order = np.argsort(w)[::-1][:2]
pcs = v[:, order] * np.sqrt(np.maximum(w[order], 0))
for j in range(pcs.shape[1]): # deterministic sign: largest |loading| positive
k = int(np.argmax(np.abs(pcs[:, j])))
if pcs[k, j] < 0:
pcs[:, j] = -pcs[:, j]
if pcs.shape[1] < 2:
pcs = np.column_stack([pcs, np.zeros(len(pcs))])
return pcs
def _thresholds(opts: MethylQcOptions, man: MethylManifest) -> Thresholds:
cli: dict[str, Any] = parse_cli_pairs(opts.thresholds)
if opts.bg_offset is not None:
cli["bgOffset"] = opts.bg_offset
return resolve_thresholds(
config=opts.config,
profile=opts.profile,
cli=cli,
array_type="methylation",
manifest_name=man.heading.get("Descriptor File Name", Path(man.path).name),
)
def run_methyl_qc(opts: MethylQcOptions, rep: Reporter) -> int:
from simurg.pipeline.inputs import check_paths, resolve_sample_set, sheet_values
rm = RunManifest(command="methyl qc", argv=opts.argv)
rep.run_id = rm.run_id
check_paths([(opts.manifest, "methylation manifest"), (opts.idats, "IDAT folder"), (opts.sheet, "sheet")])
try:
sset = resolve_sample_set(opts.idats, opts.sheet, opts.on_duplicate_idat, rep, rm)
except SimurgError as e:
if e.code == "E_NO_INPUT":
raise SimurgError("E_METH_NO_IDATS", f"No IDATs found ({e.message})") from None
raise
outputs = [opts.out / f"{SUMMARY}.{x}" for x in ("tsv", "xlsx", "html")] + [opts.out / "pcs.tsv.gz"]
check_outputs_absent(outputs, force=opts.force)
rep.info(f"reading methylation manifest {opts.manifest}")
man = read_methyl_manifest(opts.manifest)
opts.out.mkdir(parents=True, exist_ok=True)
for p in write_probe_tables(man, opts.out):
rm.add_output(p, "simurg.methyl_manifest/1")
th = _thresholds(opts, man)
for rec in th.as_records():
rm.set_option(f"qc.{rec['key']}", rec["value"], rec["source"])
beads = [ControlBead(c.type, c.color_channel, c.name, c.address, canonical_id(c.name)) for c in man.controls]
types = {c.type.upper() for c in man.controls}
if miss := [t for t in CONTROL_TYPES if t not in types]:
rep.warn("W_METH_MISSING_CONTROL_TYPES", f"manifest lacks control type(s): {', '.join(miss)}")
analytical = man.analytical()
n = len(sset.samples)
cand = np.flatnonzero(analytical)
if n > opts.pca_max_samples_full and len(cand) > opts.pca_probes:
cand = np.sort(np.random.default_rng(opts.seed).choice(cand, opts.pca_probes, replace=False))
jobs = []
for s in sset.samples:
grn = str(s.pair.grn) if s.pair is not None and s.pair.grn is not None else None
red = str(s.pair.red) if s.pair is not None and s.pair.red is not None else None
if not (grn and red):
rep.warn("W_METH_MISSING_SAMPLE", f"Missing sample: {s.sample_id}", sample_id=s.sample_id)
jobs.append(_Job(s.sample_id, grn, red, sheet_values(s, sset.sheet), str(opts.out / "samples" / s.sample_id)))
state = {"man": man, "opts": opts, "beads": beads, "bg_offset": th.bg_offset, "analytical": analytical,
"pca_idx": cand} # fmt: skip
workers = max(1, min(opts.threads or (os.cpu_count() or 1), len(jobs) or 1))
rep.info(f"methylation QC of {len(jobs)} sample(s) with {workers} worker(s)")
if workers == 1:
_W.update(state)
try:
rows = [_process(j) for j in jobs]
finally:
_W.clear()
else:
fd, path = tempfile.mkstemp(prefix=".simurg-workers-", suffix=".pkl", dir=opts.out)
try:
with os.fdopen(fd, "wb") as fh:
pickle.dump(state, fh, protocol=5)
ctx = mp.get_context("spawn")
with ProcessPoolExecutor(workers, mp_context=ctx, initializer=_init_from_file, initargs=(path,)) as ex:
rows = list(ex.map(_process, jobs))
finally:
Path(path).unlink(missing_ok=True)
metrics = metrics_for("methylation")
col_key = {m.column: m.key for m in metrics}
missing_names: dict[str, list[str]] = {}
for r in rows:
if r["status"] != "OK":
rep.error(
r["fail_reason"].split(":", 1)[0], f"{r['sample_id']}: {r['fail_reason']}", sample_id=r["sample_id"]
)
continue
q = r["control"].qc
missing_names.update(q.missing)
if not r["scan_time"]:
rep.warn("W_IDAT_NO_SCAN_TIME", f"{r['sample_id']}: No scan time available", sample_id=r["sample_id"])
fails = [m.column for m in metrics if m.column not in q.missing and th.get(m.key).fails(q.values[m.column])]
if opts.min_prop_passing is not None and not r["prop_probes_passing"] >= opts.min_prop_passing:
fails.append("prop_probes_passing")
r["fail_codes"] = ";".join(fails)
r["na_metrics"] = ";".join(q.na_metrics)
r["pass_qc"] = 0 if fails else 1
r.update({m.column: q.values[m.column] for m in metrics})
if missing_names:
ids = sorted({c for v in missing_names.values() for c in v})
rep.warn("W_METH_MISSING_CONTROL_NAMES", f"manifest lacks control(s) {', '.join(ids)}; "
f"not evaluable: {', '.join(sorted(missing_names))}") # fmt: skip
meta_cols: list[str] = []
for j in jobs:
for k in j.sheet_values:
if k not in meta_cols:
meta_cols.append(k)
cols = ["sentrix_id", "sentrix_position", "sample_id", *meta_cols, *(m.column for m in metrics), *AVG_COLUMNS,
"scan_time", "n_probes", "n_pass_detection", "prop_probes_passing", "pass_qc", "fail_codes",
"na_metrics", "status", "fail_reason"] # fmt: skip
out_rows = []
for j, r in zip(jobs, rows, strict=True):
out_rows.append({**j.sheet_values, **{c: r.get(c, "") for c in cols if c not in j.sheet_values}})
summary_tsv = opts.out / f"{SUMMARY}.tsv"
write_tsv(summary_tsv, cols, out_rows)
rm.add_output(summary_tsv, "simurg.methyl_qc_summary/1")
_write_xlsx(opts.out / f"{SUMMARY}.xlsx", cols, out_rows, th, col_key)
ok_rows = [r for r in rows if r["status"] == "OK"]
samples = [r["control"] for r in ok_rows]
if samples:
for p in write_control_tables(opts.out, samples, beads, th.bg_offset, rep, array_type="methylation"):
rm.add_output(p, "simurg.controls/1")
pcs = _pca(np.vstack([r["pca"] for r in ok_rows])) if len(ok_rows) >= 2 else None
group_col = next((k for k in meta_cols if k.casefold() == "sample_group"), None)
groups = {j.sample_id: (j.sheet_values.get(group_col, "") if group_col else "") for j in jobs}
pc_rows = []
if pcs is None:
rep.warn("W_METH_NO_FEATURES", "No features, skipping PCA plot")
else:
for r, (p1, p2) in zip(ok_rows, pcs.tolist(), strict=True):
pc_rows.append({"sample_id": r["sample_id"], "pc1": p1, "pc2": p2,
"sample_group": groups[r["sample_id"]] or "NA"}) # fmt: skip
with gzip.open(opts.out / "pcs.tsv.gz", "wt", encoding="utf-8", newline="\n") as fh:
fh.write("sample_id\tpc1\tpc2\tsample_group\n")
for pr in pc_rows:
fh.write(f"{pr['sample_id']}\t{pr['pc1']:.6g}\t{pr['pc2']:.6g}\t{pr['sample_group']}\n")
_write_html(opts.out / f"{SUMMARY}.html", out_rows, pc_rows, th, metrics, man, len(cand))
n_ok = len(ok_rows)
rm.summary = {"samples": len(rows), "prepared": n_ok, "pass_qc": sum(r.get("pass_qc") == 1 for r in ok_rows)}
rm.status = "completed"
rm.write(opts.out / "run_manifest.json")
rep.info(f"done: {n_ok}/{len(rows)} samples prepared, {rm.summary['pass_qc']} pass QC; summary {summary_tsv}")
if n_ok == len(rows):
return ExitCode.OK
return ExitCode.PARTIAL if n_ok else ExitCode.ALL_FAILED
def _write_xlsx(
path: Path, cols: list[str], rows: list[dict[str, Any]], th: Thresholds, col_key: dict[str, str]
) -> None:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
bad = PatternFill("solid", fgColor="F5C28E")
wb = Workbook()
ws = wb.active
ws.title = "Samples"
ws.append(cols)
for c in ws[1]:
c.font = Font(bold=True)
for r in rows:
ws.append([_xl(r.get(c)) for c in cols])
cur = ws.max_row
for j, c in enumerate(cols, start=1):
v = r.get(c)
na = c in str(r.get("na_metrics", "")).split(";")
metric_fails = c in col_key and isinstance(v, float) and not na and th.get(col_key[c]).fails(v)
if metric_fails or (c == "pass_qc" and v == 0):
ws.cell(cur, j).fill = bad
ws.freeze_panes = "D2"
ws.auto_filter.ref = ws.dimensions
t2 = wb.create_sheet("Thresholds")
t2.append(["key", "threshold", "direction", "source"])
for k in col_key.values():
t = th.get(k)
t2.append([k, t.value, t.direction, t.source])
tmp = path.with_suffix(".xlsx.part")
wb.save(tmp)
tmp.replace(path)
def _xl(v: Any) -> Any:
if isinstance(v, float) and not math.isfinite(v):
return "NaN" if math.isnan(v) else ("Inf" if v > 0 else "-Inf")
return "" if v is None else v
def _write_html(
path: Path,
rows: list[dict[str, Any]],
pcs: list[dict[str, Any]],
th: Thresholds,
metrics: list[Any],
man: MethylManifest,
n_pca_probes: int,
) -> None:
"""MET-013: self-contained page — summary table, detection histogram, PCA of β."""
from simurg.pipeline.qc_report import _plotly
ok = [r for r in rows if r.get("status") == "OK"]
fin = [r for r in ok if math.isfinite(r["prop_probes_passing"])]
hist = {
"pass": [r["prop_probes_passing"] for r in fin if r.get("pass_qc") == 1],
"fail": [r["prop_probes_passing"] for r in fin if r.get("pass_qc") != 1],
}
data = {
"hist": hist,
"pcs": pcs,
"n_pca_probes": n_pca_probes,
}
head = ["sample_id", "pass_qc", "prop_probes_passing", "fail_codes", "na_metrics", "scan_time"]
trs = "".join("<tr>" + "".join(f"<td>{html.escape(_cell(r.get(c)))}</td>" for c in head) + "</tr>" for r in rows)
th_rows = "".join(
f"<tr><td>{m.column}</td><td>{m.key}</td><td>{_cell(th.get(m.key).value)}</td></tr>" for m in metrics
)
generated = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d %H:%M UTC")
page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Methylation QC</title>
<style>body{{font:14px system-ui,sans-serif;margin:16px;color:#222;background:#fff}}table{{border-collapse:collapse}}
td,th{{border:1px solid #ccc;padding:3px 6px;text-align:left}}.plots{{display:flex;flex-wrap:wrap;gap:16px}}
.plot{{width:min(560px,100%);height:380px}}.note{{color:#666}}</style>
<script>{_plotly()}</script></head><body>
<h1>Methylation control QC</h1>
<p class="note">{len(rows)} samples · manifest {html.escape(Path(man.path).name)}
· profile {html.escape(th.profile or "none")} · generated {generated} · SimurgArray</p>
<div class="plots"><div id="hist" class="plot"></div><div id="pca" class="plot"></div></div>
<h2>Samples</h2><table><tr>{"".join(f"<th>{c}</th>" for c in head)}</tr>{trs}</table>
<h2>Thresholds</h2><table><tr><th>metric</th><th>key</th><th>threshold</th></tr>{th_rows}</table>
<script>const D={json.dumps(data, allow_nan=False, default=_json_num)};
Plotly.newPlot("hist",[{{x:D.hist.pass,type:"histogram",name:"pass",marker:{{color:"#4c78a8"}}}},
{{x:D.hist.fail,type:"histogram",name:"fail",marker:{{color:"#e45756"}}}}],
{{title:"Probes passing detection",barmode:"overlay",xaxis:{{title:"proportion"}},yaxis:{{title:"samples"}}}});
if(D.pcs.length){{const g=[...new Set(D.pcs.map(p=>p.sample_group))];
Plotly.newPlot("pca",g.map(k=>({{x:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc1),
y:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc2),text:D.pcs.filter(p=>p.sample_group===k).map(p=>p.sample_id),
mode:"markers",type:"scatter",name:k}})),{{title:"PCA of beta ("+D.n_pca_probes+" candidate probes)",
xaxis:{{title:"PC1"}},yaxis:{{title:"PC2"}}}});}}
else{{document.getElementById("pca").textContent="No features, PCA skipped";}}
</script></body></html>
"""
tmp = path.with_suffix(".html.part")
tmp.write_text(page, encoding="utf-8")
tmp.replace(path)
def _cell(v: Any) -> str:
if v is None:
return ""
if isinstance(v, float):
return "NaN" if math.isnan(v) else f"{v:.4g}"
return str(v)
def _json_num(v: Any) -> Any:
if isinstance(v, np.floating | np.integer):
return v.item()
raise TypeError(type(v))
def run_convert_manifest(manifest: Path, out: Path, force: bool, rep: Reporter) -> int:
"""``simurg methyl convert-manifest`` (MET-003)."""
check_outputs_absent([out / "probes.tsv", out / "controls.tsv"], force=force)
man = read_methyl_manifest(manifest)
probes, ctl = write_probe_tables(man, out)
rep.info(f"{man.num_probes} probes -> {probes}; {len(man.controls)} controls -> {ctl}")
return ExitCode.OK
+20 -7
View File
@@ -36,6 +36,7 @@ from simurg.qc.controls import (
ControlBead,
ControlQC,
beads_from_definitions,
canonical_id,
compare_definitions,
compute_control_metrics,
control_intensities,
@@ -123,9 +124,10 @@ def control_sample(
beads: Sequence[ControlBead],
bg_offset: float,
sheet_values: Mapping[str, str] | None = None,
array_type: str = "genotyping",
) -> ControlSample:
g, r = control_intensities(grn, red, beads)
qc = compute_control_metrics(beads, g, r, "genotyping", bg_offset)
qc = compute_control_metrics(beads, g, r, array_type, bg_offset)
return ControlSample(sample_id, idat_metadata(grn, red, sheet_values), g, r, qc)
@@ -250,8 +252,8 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
rm = RunManifest(command="qc controls", argv=opts.argv)
rep.run_id = rm.run_id
if opts.array_type == "methylation":
raise SimurgError("E_QC_METHYLATION", "methylation control metrics are planned for v2 (QC-006)")
if opts.array_type == "methylation" and opts.csv is None:
raise SimurgError("E_NO_INPUT", "methylation control QC needs the methylation CSV manifest (--csv)")
if opts.bpm is None and opts.csv is None:
raise SimurgError("E_NO_INPUT", "give --bpm or --csv for the control definitions (QC-001)")
if opts.bg_offset < 0:
@@ -261,8 +263,15 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
names = (COMPAT_RAW, COMPAT_QC) if opts.compat else (CONTROLS_RAW, CONTROLS_QC)
targets = [opts.out / n for n in names]
check_outputs_absent(targets, force=opts.force) # QC-014: other files in the folder are fine
manifest = read_bpm(opts.bpm) if opts.bpm else None
beads = load_control_beads(manifest, opts.csv, rep)
if opts.array_type == "methylation":
from simurg.formats.methyl_manifest import read_methyl_manifest
assert opts.csv is not None
mman = read_methyl_manifest(opts.csv)
beads = [ControlBead(c.type, c.color_channel, c.name, c.address, canonical_id(c.name)) for c in mman.controls]
else:
manifest = read_bpm(opts.bpm) if opts.bpm else None
beads = load_control_beads(manifest, opts.csv, rep)
if not beads:
raise SimurgError("E_BPM_FORMAT", "the manifest has no control definitions")
samples: list[ControlSample] = []
@@ -273,7 +282,9 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
assert s.pair.grn is not None and s.pair.red is not None
grn, red = read_idat(s.pair.grn), read_idat(s.pair.red)
check_pair(grn, red)
samples.append(control_sample(s.sample_id, grn, red, beads, opts.bg_offset, sheet_values(s, sset.sheet)))
samples.append(
control_sample(s.sample_id, grn, red, beads, opts.bg_offset, sheet_values(s, sset.sheet), opts.array_type)
)
# QC-014: sample IDs of an existing summary must match
summary = opts.out / "sample_summary.tsv"
if summary.exists():
@@ -282,7 +293,9 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
if ids ^ mine:
rep.warn("W_QC_UNMATCHED_SAMPLES", f"{len(ids ^ mine)} sample ID(s) differ from {summary}")
opts.out.mkdir(parents=True, exist_ok=True)
written = write_control_tables(opts.out, samples, beads, opts.bg_offset, rep, compat=opts.compat)
written = write_control_tables(
opts.out, samples, beads, opts.bg_offset, rep, array_type=opts.array_type, compat=opts.compat
)
for p in written:
rm.add_output(p, "simurg.controls/1")
rm.summary = {"samples": len(samples)}
-2
View File
@@ -593,8 +593,6 @@ def run_qc_report(opts: QcReportOptions, rep: Reporter) -> int:
"Genotyping and methylation datasets cannot be mixed: "
+ "; ".join(f"{t}: {', '.join(v)}" for t, v in by_type.items()),
)
if "methylation" in types:
raise SimurgError("E_QC_METHYLATION", "methylation datasets in the report arrive with v2 (QC-062)")
total = sum(len(ds.samples) for ds in datasets)
if total > 20000:
rep.warn("W_QC_TOO_FEW", f"{total} samples in total: the report may be slow to open (QC-057)")