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 d235dc5419
commit 3eb51639cc
13 files changed
+1292 -21

No files matched your search

+10 -1
View File
@@ -26,6 +26,12 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_IDAT_MANIFEST_MISMATCH` | error | IDATs and manifest belong to different products | IN-027 | | `E_IDAT_MANIFEST_MISMATCH` | error | IDATs and manifest belong to different products | IN-027 |
| `E_IDAT_PAIR_MISMATCH` | error | Red and Grn IDATs of a sample do not match | IN-023 | | `E_IDAT_PAIR_MISMATCH` | error | Red and Grn IDATs of a sample do not match | IN-023 |
| `E_INTERNAL` | error | Internal error (bug); rerun with --debug and report | CLI-014 | | `E_INTERNAL` | error | Internal error (bug); rerun with --debug and report | CLI-014 |
| `E_METH_CONTROLS_FORMAT` | error | Controls not formatted correctly. Must have 4 columns (Address,Type,Color_Channel,Name) | MET-002 |
| `E_METH_MISSING_COLUMNS` | error | Missing required columns in the methylation manifest | MET-002 |
| `E_METH_NO_ASSAY` | error | Probe section not found | MET-002 |
| `E_METH_NO_CONTROLS` | error | Missing control probes in manifest | MET-002 |
| `E_METH_NO_IDATS` | error | No IDATs found | MET-004 |
| `E_METH_PREP_FAILED` | error | Prep failed (sample) | MET-010 |
| `E_NO_INPUT` | error | Neither an input folder nor a sample sheet was given | CLI-006 | | `E_NO_INPUT` | error | Neither an input folder nor a sample sheet was given | CLI-006 |
| `E_NO_INPUT_FOLDER` | error | Sample sheet has no RootFolder and no folder was given | CLI-006 | | `E_NO_INPUT_FOLDER` | error | Sample sheet has no RootFolder and no folder was given | CLI-006 |
| `E_OUTPUT_EXISTS` | error | Outputs already exist; use --force or --resume | CLI-010 | | `E_OUTPUT_EXISTS` | error | Outputs already exist; use --force or --resume | CLI-010 |
@@ -37,7 +43,6 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_QC_FORMAT` | error | Invalid --output-format value | QC-063 | | `E_QC_FORMAT` | error | Invalid --output-format value | QC-063 |
| `E_QC_LABEL_COUNT` | error | Number of labels does not match number of datasets | QC-042 | | `E_QC_LABEL_COUNT` | error | Number of labels does not match number of datasets | QC-042 |
| `E_QC_LABEL_DUPLICATE` | error | Dataset labels are not unique | QC-042 | | `E_QC_LABEL_DUPLICATE` | error | Dataset labels are not unique | QC-042 |
| `E_QC_METHYLATION` | error | Methylation control metrics are not available yet (v2) | QC-006 |
| `E_QC_MIXED_TYPES` | error | Genotyping and methylation datasets cannot be mixed | QC-043 | | `E_QC_MIXED_TYPES` | error | Genotyping and methylation datasets cannot be mixed | QC-043 |
| `E_QC_NO_DATASETS` | error | No QC dataset folder found | QC-041 | | `E_QC_NO_DATASETS` | error | No QC dataset folder found | QC-041 |
| `E_QC_PROFILE` | error | Unknown QC profile | QC-024 | | `E_QC_PROFILE` | error | Unknown QC profile | QC-024 |
@@ -73,6 +78,10 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `W_MERGE_ALL_NC` | warning | Every probe at a merged site is a no-call | OUT-031 | | `W_MERGE_ALL_NC` | warning | Every probe at a merged site is a no-call | OUT-031 |
| `W_MERGE_AMBIGUOUS` | warning | Probes at a site allow several genotypes; set missing | OUT-031 | | `W_MERGE_AMBIGUOUS` | warning | Probes at a site allow several genotypes; set missing | OUT-031 |
| `W_MERGE_INCONSISTENT` | warning | Probes at a site disagree; genotype set missing | OUT-031 | | `W_MERGE_INCONSISTENT` | warning | Probes at a site disagree; genotype set missing | OUT-031 |
| `W_METH_MISSING_CONTROL_NAMES` | warning | Control names missing from the manifest; metric set to NA | MET-009 |
| `W_METH_MISSING_CONTROL_TYPES` | warning | Control types missing from the manifest; metric set to NA | MET-009 |
| `W_METH_MISSING_SAMPLE` | warning | Missing sample: IDATs absent; the sample fails, the run continues | MET-015 |
| `W_METH_NO_FEATURES` | warning | No features, skipping PCA plot | MET-013 |
| `W_NO_BGZIP` | warning | Uncompressed VCF written; downstream commands need bgzip + index | OUT-010 | | `W_NO_BGZIP` | warning | Uncompressed VCF written; downstream commands need bgzip + index | OUT-010 |
| `W_QC_LABEL_SANITIZED` | warning | Dataset label was sanitized | QC-042 | | `W_QC_LABEL_SANITIZED` | warning | Dataset label was sanitized | QC-042 |
| `W_QC_NO_BAF` | warning | GTC has no B-allele frequencies; contamination not estimated | QC-064 | | `W_QC_NO_BAF` | warning | GTC has no B-allele frequencies; contamination not estimated | QC-064 |
+66
View File
@@ -666,6 +666,72 @@ def qc_controls_cmd(
run_guarded(rep, lambda: run_qc_controls(opts, rep)) run_guarded(rep, lambda: run_qc_controls(opts, rep))
# --------------------------------------------------------------------------------------------------------
methyl_app = typer.Typer(help="Methylation arrays: control QC, detection, beta values (MET-001).")
app.add_typer(methyl_app, name="methyl")
@methyl_app.command("qc")
def methyl_qc_cmd(
manifest: Annotated[Path, typer.Option("--manifest", help="Methylation CSV manifest (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
idats: Annotated[Path | None, typer.Option("--idats", help="Folder searched recursively for IDAT pairs.")] = None,
sheet: Annotated[Path | None, typer.Option("--sheet", help="Sample sheet (CSV).")] = None,
config: Annotated[Path | None, typer.Option("--config", help="QC threshold YAML.")] = None,
profile: Annotated[str, typer.Option("--profile", help="QC profile name, 'auto' or 'none'.")] = "auto",
threshold: Annotated[list[str] | None, typer.Option("--threshold", help="KEY=VALUE (repeatable).")] = None,
bg_offset: Annotated[float | None, typer.Option("--bg-offset", help="Background offset x (3000).")] = None,
bs_set: Annotated[str, typer.Option("--bs-conv-red-bg-set", help="U3-U5 (default) or U4-U6.")] = "U3-U5",
pval: Annotated[float, typer.Option("--pval-threshold", help="Detection p threshold.")] = 0.05,
beta_offset: Annotated[float, typer.Option("--beta-offset", help="alpha in M/(M+U+alpha).")] = 0.0,
dye_bias: Annotated[str, typer.Option("--dye-bias", help="linear|nonlinear")] = "linear",
min_prop: Annotated[
float | None, typer.Option("--min-prop-passing", help="Fail samples below this detection rate.")
] = None,
pca_full: Annotated[int, typer.Option("--pca-max-samples-full", min=2)] = 100,
pca_probes: Annotated[int, typer.Option("--pca-probes", min=2)] = 10_000,
threads: Annotated[int, typer.Option("--threads", min=0, max=1024, help="Worker processes (0 = auto).")] = 0,
force: Annotated[bool, typer.Option("--force", help="Replace existing outputs.")] = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Methylation control QC: 21 control metrics, detection p-values, noob/dye-bias corrected β (MET-001)."""
from simurg.pipeline.methyl import MethylQcOptions, run_methyl_qc
if dye_bias not in ("linear", "nonlinear"):
raise typer.BadParameter("--dye-bias must be linear or nonlinear")
if bs_set not in ("U3-U5", "U4-U6"):
raise typer.BadParameter("--bs-conv-red-bg-set must be U3-U5 or U4-U6")
if not 0 < pval <= 1:
raise typer.BadParameter("--pval-threshold must be in (0, 1]")
rep = _reporter("methyl qc", out, log_json, debug, quiet)
opts = MethylQcOptions(
out=out, manifest=manifest, idats=idats, sheet=sheet, config=config,
profile=None if profile == "none" else profile, thresholds=threshold or [], bg_offset=bg_offset,
bs_conv_red_bg_set=bs_set, pval_threshold=pval, beta_offset=beta_offset, dye_bias=dye_bias,
min_prop_passing=min_prop, pca_max_samples_full=pca_full, pca_probes=pca_probes, threads=threads,
force=force, argv=sys.argv[1:],
) # fmt: skip
run_guarded(rep, lambda: run_methyl_qc(opts, rep))
@methyl_app.command("convert-manifest")
def methyl_convert_cmd(
manifest: Annotated[Path, typer.Option("--manifest", help="Methylation CSV manifest (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
force: Annotated[bool, typer.Option("--force", help="Replace existing outputs.")] = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Methylation manifest -> probes.tsv and controls.tsv (MET-003)."""
from simurg.pipeline.methyl import run_convert_manifest
rep = _reporter("methyl convert-manifest", out, log_json, debug, quiet)
run_guarded(rep, lambda: run_convert_manifest(manifest, out, force, rep))
def _qc_cohort( def _qc_cohort(
command: str, command: str,
out: Path, out: Path,
+31 -1
View File
@@ -116,7 +116,6 @@ CATALOGUE: dict[str, MessageDef] = dict(
_m("W_CTRL_MISSING", "warning", "Controls needed by a metric are absent; metric not evaluable", "QC-007"), _m("W_CTRL_MISSING", "warning", "Controls needed by a metric are absent; metric not evaluable", "QC-007"),
_m("W_CTRL_ZERO_DIVISOR", "warning", "Control metric has a zero denominator (NaN)", "QC-008"), _m("W_CTRL_ZERO_DIVISOR", "warning", "Control metric has a zero denominator (NaN)", "QC-008"),
_m("W_CTRL_DEFS_DIFFER", "warning", "BPM and CSV control definitions differ; the BPM wins", "QC-001"), _m("W_CTRL_DEFS_DIFFER", "warning", "BPM and CSV control definitions differ; the BPM wins", "QC-001"),
_m("E_QC_METHYLATION", "error", "Methylation control metrics are not available yet (v2)", "QC-006"),
_m("E_QC_NO_DATASETS", "error", "No QC dataset folder found", "QC-041"), _m("E_QC_NO_DATASETS", "error", "No QC dataset folder found", "QC-041"),
_m("E_QC_DATASET_INCOMPLETE", "error", "Dataset folder lacks a required file", "QC-041"), _m("E_QC_DATASET_INCOMPLETE", "error", "Dataset folder lacks a required file", "QC-041"),
_m("E_QC_LABEL_COUNT", "error", "Number of labels does not match number of datasets", "QC-042"), _m("E_QC_LABEL_COUNT", "error", "Number of labels does not match number of datasets", "QC-042"),
@@ -136,6 +135,37 @@ CATALOGUE: dict[str, MessageDef] = dict(
_m("W_QC_PEDIGREE", "warning", "Parent1/Parent2/Replicate value does not name a sample in the set", "IN-018"), _m("W_QC_PEDIGREE", "warning", "Parent1/Parent2/Replicate value does not name a sample in the set", "IN-018"),
_m("W_QC_NO_BAF", "warning", "GTC has no B-allele frequencies; contamination not estimated", "QC-064"), _m("W_QC_NO_BAF", "warning", "GTC has no B-allele frequencies; contamination not estimated", "QC-064"),
_m("W_QC_TOO_FEW", "warning", "Too few samples or sites for a cohort QC statistic", "QC-026"), _m("W_QC_TOO_FEW", "warning", "Too few samples or sites for a cohort QC statistic", "QC-026"),
# --- methylation control QC (MET-001 .. MET-018) -------------------------------------------------
_m("E_METH_NO_ASSAY", "error", "Probe section not found", "MET-002"),
_m("E_METH_NO_CONTROLS", "error", "Missing control probes in manifest", "MET-002"),
_m("E_METH_MISSING_COLUMNS", "error", "Missing required columns in the methylation manifest", "MET-002"),
_m(
"E_METH_CONTROLS_FORMAT",
"error",
"Controls not formatted correctly. Must have 4 columns (Address,Type,Color_Channel,Name)",
"MET-002",
),
_m("E_METH_NO_IDATS", "error", "No IDATs found", "MET-004"),
_m(
"W_METH_MISSING_SAMPLE",
"warning",
"Missing sample: IDATs absent; the sample fails, the run continues",
"MET-015",
),
_m("E_METH_PREP_FAILED", "error", "Prep failed (sample)", "MET-010"),
_m(
"W_METH_MISSING_CONTROL_TYPES",
"warning",
"Control types missing from the manifest; metric set to NA",
"MET-009",
),
_m(
"W_METH_MISSING_CONTROL_NAMES",
"warning",
"Control names missing from the manifest; metric set to NA",
"MET-009",
),
_m("W_METH_NO_FEATURES", "warning", "No features, skipping PCA plot", "MET-013"),
] ]
) )
+157
View File
@@ -0,0 +1,157 @@
"""Methylation CSV manifest (EPIC v1/v2, MSA and custom designs) — spec MET-002, MET-003; plan §6.3.
Sections: ``[Heading]`` (key,value), ``[Assay]`` (header row + one row per probe; needs ``IlmnID``, ``AddressA_ID``,
``AddressB_ID``, ``Color_Channel``) and ``[Controls]`` (``Address,Type,Color_Channel,Name``, no header row; the
vendor files pad lines with empty fields). Infinium I probes have an ``AddressB_ID``; the colour channel is read
from ``Color_Channel`` (``Red``/``Grn``), Infinium II probes leave both empty.
[DOC: Illumina methylation manifest column descriptions; layout of the open-source toolkits' manifests (MET-003)]
"""
from __future__ import annotations
import csv
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import numpy.typing as npt
from simurg.core.errors import FormatError
from simurg.formats.csv_manifest import _open_text
REQUIRED = ("IlmnID", "AddressA_ID", "AddressB_ID", "Color_Channel")
@dataclass
class MethylControl:
address: int
type: str
color_channel: str
name: str
@property
def probe_id(self) -> str:
return f"ctl_{self.address}"
@dataclass
class MethylManifest:
path: str
heading: dict[str, str]
probe_ids: list[str]
u: npt.NDArray[np.int64] # AddressA_ID
m: npt.NDArray[np.int64] # AddressB_ID; 0 for Infinium II
col: npt.NDArray[np.str_] # "R" / "G" for Infinium I, "" for Infinium II
controls: list[MethylControl] = field(default_factory=list)
@property
def num_probes(self) -> int:
return len(self.probe_ids)
@property
def type_i(self) -> npt.NDArray[np.bool_]:
return np.asarray(self.m != 0, dtype=np.bool_)
def analytical(self) -> npt.NDArray[np.bool_]:
"""CG and SNP probes (MET-011): IDs starting with ``cg`` or ``rs``."""
return np.array([p[:2] in ("cg", "rs") for p in self.probe_ids], dtype=np.bool_)
def _address(s: str) -> int:
s = s.strip()
return int(s) if s else 0
def _channel(s: str, *, infinium_i: bool) -> str:
c = s.strip().casefold()
if not infinium_i:
return ""
if c in ("red", "r"):
return "R"
if c in ("grn", "green", "g"):
return "G"
return "?"
def read_methyl_manifest(path: str | Path) -> MethylManifest:
"""Parse a methylation CSV manifest (plain or gzipped). Raises the MET-002 errors."""
p = Path(path)
spath = str(p)
heading: dict[str, str] = {}
header: list[str] | None = None
idx: dict[str, int] = {}
ids: list[str] = []
u: list[int] = []
m: list[int] = []
col: list[str] = []
controls: list[MethylControl] = []
section = None
saw_assay = saw_controls = False
with _open_text(p) as fh:
for row in csv.reader(fh):
if not row or not any(c.strip() for c in row):
continue
first = row[0].strip()
if first.startswith("[") and first.endswith("]"):
section = first[1:-1].strip().casefold()
saw_assay |= section == "assay"
saw_controls |= section == "controls"
continue
if section == "heading":
heading[first] = row[1].strip() if len(row) > 1 else ""
elif section == "assay":
if header is None:
header = [c.strip() for c in row]
idx = {c: i for i, c in enumerate(header)}
miss = [c for c in REQUIRED if c not in idx]
if miss:
msg = f"{spath}: Missing required columns: {', '.join(miss)}"
raise FormatError("E_METH_MISSING_COLUMNS", msg, path=spath)
iu, im, ic, ii = idx["AddressA_ID"], idx["AddressB_ID"], idx["Color_Channel"], idx["IlmnID"]
continue
ids.append(row[ii].strip())
a, b = _address(row[iu]), _address(row[im]) if im < len(row) else 0
u.append(a)
m.append(b)
col.append(_channel(row[ic] if ic < len(row) else "", infinium_i=b != 0))
elif section == "controls":
fields = list(row)
while fields and not fields[-1].strip():
fields.pop()
if len(fields) < 4: # vendor EPIC files add a 5th field ("AVG") to some NEGATIVE lines (K-026)
raise FormatError(
"E_METH_CONTROLS_FORMAT",
f"{spath}: Controls not formatted correctly. Must have 4 columns "
f"(Address,Type,Color_Channel,Name): {','.join(fields)[:80]!r}",
path=spath,
)
addr, typ, colour, name = (f.strip() for f in fields[:4])
controls.append(MethylControl(_address(addr), typ, colour, name))
if not saw_assay or header is None:
raise FormatError("E_METH_NO_ASSAY", f"{spath}: Probe section not found", path=spath)
if not saw_controls or not controls:
raise FormatError("E_METH_NO_CONTROLS", f"{spath}: Missing control probes in manifest", path=spath)
return MethylManifest(
path=spath,
heading=heading,
probe_ids=ids,
u=np.asarray(u, dtype=np.int64),
m=np.asarray(m, dtype=np.int64),
col=np.asarray(col, dtype="<U1"),
controls=controls,
)
def write_probe_tables(man: MethylManifest, out: Path) -> tuple[Path, Path]:
"""MET-003: ``probes.tsv`` (probe_id, u, m, col; ``NA`` for Infinium II) and ``controls.tsv``."""
out.mkdir(parents=True, exist_ok=True)
probes, ctl = out / "probes.tsv", out / "controls.tsv"
with probes.open("w", encoding="utf-8", newline="\n") as fh:
fh.write("probe_id\tu\tm\tcol\n")
for pid, a, b, c in zip(man.probe_ids, man.u.tolist(), man.m.tolist(), man.col.tolist(), strict=True):
fh.write(f"{pid}\t{a}\t{b if b else 'NA'}\t{c or 'NA'}\n")
with ctl.open("w", encoding="utf-8", newline="\n") as fh:
fh.write("address\ttype\tcolor_channel\tname\tprobe_id\n")
for c in man.controls:
fh.write(f"{c.address}\t{c.type}\t{c.color_channel}\t{c.name}\t{c.probe_id}\n")
return probes, ctl
+8 -2
View File
@@ -15,7 +15,12 @@ from pathlib import Path
from simurg.core.errors import SimurgError from simurg.core.errors import SimurgError
MAX_LEN = 500 MAX_LEN = 500
KEY_PAIRS = (("SentrixBarcode_A", "SentrixPosition_A"), ("beadChipName", "sampleSectionName")) # the third pair is the methylation toolkits' convention (minfi/sesame sheets), accepted for MET-004 (K-026)
KEY_PAIRS = (
("SentrixBarcode_A", "SentrixPosition_A"),
("beadChipName", "sampleSectionName"),
("Sentrix_ID", "Sentrix_Position"),
)
RECOGNISED = ( RECOGNISED = (
"Sample_ID", "Sample_ID",
"Sample_Name", "Sample_Name",
@@ -246,7 +251,8 @@ def parse_samplesheet(path: str | Path, *, on_duplicate_sample: str = "error") -
raise _err( raise _err(
"E_SHEET_NO_KEY_COLUMNS", "E_SHEET_NO_KEY_COLUMNS",
spath, spath,
"missing SentrixBarcode_A/SentrixPosition_A (or beadChipName/sampleSectionName) columns", "missing SentrixBarcode_A/SentrixPosition_A (or beadChipName/sampleSectionName, "
"Sentrix_ID/Sentrix_Position) columns",
) )
recognised = {fold(c) for c in RECOGNISED} | {fold(key[0]), fold(key[1])} recognised = {fold(c) for c in RECOGNISED} | {fold(key[0]), fold(key[1])}
+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, ControlBead,
ControlQC, ControlQC,
beads_from_definitions, beads_from_definitions,
canonical_id,
compare_definitions, compare_definitions,
compute_control_metrics, compute_control_metrics,
control_intensities, control_intensities,
@@ -123,9 +124,10 @@ def control_sample(
beads: Sequence[ControlBead], beads: Sequence[ControlBead],
bg_offset: float, bg_offset: float,
sheet_values: Mapping[str, str] | None = None, sheet_values: Mapping[str, str] | None = None,
array_type: str = "genotyping",
) -> ControlSample: ) -> ControlSample:
g, r = control_intensities(grn, red, beads) 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) 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) rm = RunManifest(command="qc controls", argv=opts.argv)
rep.run_id = rm.run_id rep.run_id = rm.run_id
if opts.array_type == "methylation": if opts.array_type == "methylation" and opts.csv is None:
raise SimurgError("E_QC_METHYLATION", "methylation control metrics are planned for v2 (QC-006)") raise SimurgError("E_NO_INPUT", "methylation control QC needs the methylation CSV manifest (--csv)")
if opts.bpm is None and opts.csv is None: 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)") raise SimurgError("E_NO_INPUT", "give --bpm or --csv for the control definitions (QC-001)")
if opts.bg_offset < 0: 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) names = (COMPAT_RAW, COMPAT_QC) if opts.compat else (CONTROLS_RAW, CONTROLS_QC)
targets = [opts.out / n for n in names] targets = [opts.out / n for n in names]
check_outputs_absent(targets, force=opts.force) # QC-014: other files in the folder are fine 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 if opts.array_type == "methylation":
beads = load_control_beads(manifest, opts.csv, rep) 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: if not beads:
raise SimurgError("E_BPM_FORMAT", "the manifest has no control definitions") raise SimurgError("E_BPM_FORMAT", "the manifest has no control definitions")
samples: list[ControlSample] = [] 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 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) grn, red = read_idat(s.pair.grn), read_idat(s.pair.red)
check_pair(grn, 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 # QC-014: sample IDs of an existing summary must match
summary = opts.out / "sample_summary.tsv" summary = opts.out / "sample_summary.tsv"
if summary.exists(): if summary.exists():
@@ -282,7 +293,9 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
if ids ^ mine: if ids ^ mine:
rep.warn("W_QC_UNMATCHED_SAMPLES", f"{len(ids ^ mine)} sample ID(s) differ from {summary}") rep.warn("W_QC_UNMATCHED_SAMPLES", f"{len(ids ^ mine)} sample ID(s) differ from {summary}")
opts.out.mkdir(parents=True, exist_ok=True) 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: for p in written:
rm.add_output(p, "simurg.controls/1") rm.add_output(p, "simurg.controls/1")
rm.summary = {"samples": len(samples)} 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: " "Genotyping and methylation datasets cannot be mixed: "
+ "; ".join(f"{t}: {', '.join(v)}" for t, v in by_type.items()), + "; ".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) total = sum(len(ds.samples) for ds in datasets)
if total > 20000: if total > 20000:
rep.warn("W_QC_TOO_FEW", f"{total} samples in total: the report may be slow to open (QC-057)") rep.warn("W_QC_TOO_FEW", f"{total} samples in total: the report may be slow to open (QC-057)")
+14
View File
@@ -51,6 +51,20 @@ canonical:
bs conversion i u3: bs1_U3 bs conversion i u3: bs1_U3
bs conversion i u4: bs1_U4 bs conversion i u4: bs1_U4
bs conversion i u5: bs1_U5 bs conversion i u5: bs1_U5
bs conversion i-c1: bs1_C1
bs conversion i-c2: bs1_C2
bs conversion i-c3: bs1_C3
bs conversion i-c4: bs1_C4
bs conversion i-c5: bs1_C5
bs conversion i-c6: bs1_C6
bs conversion i-u1: bs1_U1
bs conversion i-u2: bs1_U2
bs conversion i-u3: bs1_U3
bs conversion i-u4: bs1_U4
bs conversion i-u5: bs1_U5
bs conversion i-u6: bs1_U6
bs conversion i c6: bs1_C6
bs conversion i u6: bs1_U6
bs conversion ii-1: bs2_1 bs conversion ii-1: bs2_1
bs conversion ii-2: bs2_2 bs conversion ii-2: bs2_2
bs conversion ii-3: bs2_3 bs conversion ii-3: bs2_3
+18 -4
View File
@@ -71,6 +71,10 @@ METRICS: tuple[Metric, ...] = (
Metric("bs_conv_2", "bisulfiteConversionII", False, True), Metric("bs_conv_2", "bisulfiteConversionII", False, True),
Metric("bs_conv_2_bg", "bisulfiteConversionIIBg", False, True), Metric("bs_conv_2_bg", "bisulfiteConversionIIBg", False, True),
) )
GTMM_PM = [f"gtmm_{i}_pm" for i in range(1, 7)]
GTMM_MM = [f"gtmm_{i}_mm" for i in range(1, 7)]
SPEC2 = ["spec2_1", "spec2_2", "spec2_3"]
BS2 = ["bs2_1", "bs2_2", "bs2_3", "bs2_4"]
ASSAY_CONTROL_COLUMN = "assay_control_norm_r" # QC-005: copied from the sample summary ASSAY_CONTROL_COLUMN = "assay_control_norm_r" # QC-005: copied from the sample summary
COMPAT_METRIC_NAMES = { COMPAT_METRIC_NAMES = {
"restoration": "RestorationQC", "restoration": "RestorationQC",
@@ -223,14 +227,12 @@ def compute_control_metrics(
red: npt.NDArray[np.float64], red: npt.NDArray[np.float64],
array_type: str = "genotyping", array_type: str = "genotyping",
bg_offset: float = DEFAULT_BG_OFFSET, bg_offset: float = DEFAULT_BG_OFFSET,
bs_conv_red_bg_set: str = "U3-U5",
) -> ControlQC: ) -> ControlQC:
"""QC-003/QC-004 metrics of one sample. Single-probe values are bead means; min/max run over all beads (QC-002).""" """QC-003/QC-004 metrics of one sample. Single-probe values are bead means; min/max run over all beads (QC-002)."""
if array_type != "genotyping":
from simurg.core.errors import SimurgError
raise SimurgError("E_QC_METHYLATION", "methylation control metrics are planned for v2 (QC-006)")
p = _Probes(beads, green, red) p = _Probes(beads, green, red)
x = bg_offset x = bg_offset
bs_red_u = ["bs1_U3", "bs1_U4", "bs1_U5"] if bs_conv_red_bg_set == "U3-U5" else ["bs1_U4", "bs1_U5", "bs1_U6"]
formulas: dict[str, tuple[Any, Any]] = { formulas: dict[str, tuple[Any, Any]] = {
# column: (numerator thunk, denominator thunk); each thunk records the probes it needs # column: (numerator thunk, denominator thunk); each thunk records the probes it needs
"restoration": (lambda: p.mean("restoration", "G"), lambda: p.max(["ext_A", "ext_T"], "G") + x), "restoration": (lambda: p.mean("restoration", "G"), lambda: p.max(["ext_A", "ext_T"], "G") + x),
@@ -246,6 +248,18 @@ def compute_control_metrics(
"nsb_red": (lambda: p.max(["ext_C", "ext_G"], "R") + x, lambda: p.max(["nsb_bkg"], "R")), "nsb_red": (lambda: p.max(["ext_C", "ext_G"], "R") + x, lambda: p.max(["nsb_bkg"], "R")),
"np_green": (lambda: p.min(["np_C", "np_G"], "G"), lambda: p.max(["np_A", "np_T"], "G")), "np_green": (lambda: p.min(["np_C", "np_G"], "G"), lambda: p.max(["np_A", "np_T"], "G")),
"np_red": (lambda: p.min(["np_A", "np_T"], "R"), lambda: p.max(["np_C", "np_G"], "R")), "np_red": (lambda: p.min(["np_A", "np_T"], "R"), lambda: p.max(["np_C", "np_G"], "R")),
# methylation (QC-004 rows 9, 15-24; MET-006)
"target_removal_2": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.mean("target_removal_2", "G")),
"specificity_1_green": (lambda: p.min(GTMM_PM[:3], "G"), lambda: p.max(GTMM_MM[:3], "G")),
"specificity_1_red": (lambda: p.min(GTMM_PM[3:], "R"), lambda: p.max(GTMM_MM[3:], "R")),
"specificity_2": (lambda: p.min(SPEC2, "R"), lambda: p.max(SPEC2, "G")),
"specificity_2_bg": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.max(SPEC2, "G")),
"bs_conv_1_green": (lambda: p.min(["bs1_C1", "bs1_C2"], "G"), lambda: p.max(["bs1_U1", "bs1_U2"], "G")),
"bs_conv_1_green_bg": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.max(["bs1_U1", "bs1_U2"], "G")),
"bs_conv_1_red": (lambda: p.min(["bs1_C3", "bs1_C4", "bs1_C5"], "R"), lambda: p.max(bs_red_u, "R")),
"bs_conv_1_red_bg": (lambda: p.max(["ext_C", "ext_G"], "R") + x, lambda: p.max(bs_red_u, "R")),
"bs_conv_2": (lambda: p.min(BS2, "R"), lambda: p.max(BS2, "G")),
"bs_conv_2_bg": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.max(BS2, "G")),
} }
res = ControlQC(values={}) res = ControlQC(values={})
for m in metrics_for(array_type): for m in metrics_for(array_type):
+288
View File
@@ -0,0 +1,288 @@
"""Methylation signal processing — spec MET-005, MET-010 … MET-012 (plan §7, v2). Pure functions, no I/O.
* Raw signals (MET-005): Infinium I — M = channel[AddressB], U = channel[AddressA] in the probe's colour channel,
out-of-band = the other channel; Infinium II — M = Grn[AddressA], U = Red[AddressA]. Addresses missing from the
IDAT or read from < 3 beads are masked.
* Detection p-value, pOOBAH [LIT: Zhou, Laird & Shen 2017 NAR 45:e22; OSS: sesame `pOOBAH`, MIT]: empirical CDF of
the out-of-band Infinium I signal plus the negative controls per channel; p = 1 − max(F(M), F(U)) with each signal
read in its own channel (Infinium II: M green, U red).
* Background correction, noob [LIT: Triche et al. 2013 NAR 41:e90; OSS: sesame `noob`, MIT; limma `normexp.signal`,
GPL — formula only]: normal-exponential convolution per channel, background = out-of-band + negative controls,
µ and σ from Huber's M-estimator (MASS `huber`, k = 1.5, MAD scale), α = max(Huber(in-band) − µ, 10); offset 15.
* Dye bias (MET-010 step 3) [OURS]: linear — scale red and green so the means of the normalization controls
(NORM_A/T red, NORM_C/G green) meet at their average; nonlinear — quantile matching of the Infinium I red and green
in-band signals to their average quantile function.
* β = M / (M + U + α) with signals floored at 1; M-value = log2(β' / (1 − β')), β' clipped to [1e-6, 1 − 1e-6].
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
from simurg.formats.idat import Idat
from simurg.formats.methyl_manifest import MethylManifest
from simurg.signal.raw_xy import AddressIndex
F64 = npt.NDArray[np.float64]
Mask = npt.NDArray[np.bool_]
MIN_BEADS = 3
NOOB_OFFSET = 15.0
@dataclass
class MethylSignals:
"""In-band M/U per probe (float64, NaN where masked), out-of-band signal of Infinium I probes, control
intensities (manifest control order) and the probe layout needed to split them by channel."""
m: F64
u: F64
oob_m: F64 # Infinium I: the other channel at the M address; NaN for Infinium II
oob_u: F64
masked: Mask
col: npt.NDArray[np.str_] # "R" / "G" / "" (Infinium II)
ctrl_green: F64
ctrl_red: F64
ctrl_type: list[str]
@property
def type_i(self) -> Mask:
return np.asarray(self.col != "", dtype=np.bool_)
def assemble(grn: Idat, red: Idat, man: MethylManifest, *, min_beads: int = MIN_BEADS) -> MethylSignals:
"""MET-005 raw signal assembly."""
gi, ri = AddressIndex(grn), AddressIndex(red)
def read(addrs: npt.NDArray[np.int64]) -> tuple[F64, F64, Mask]:
rg, fg = gi.lookup(addrs)
rr, fr = ri.lookup(addrs)
g = grn.mean[rg].astype(np.float64)
r = red.mean[rr].astype(np.float64)
ok = fg & fr & (grn.n_beads[rg] >= min_beads) & (red.n_beads[rr] >= min_beads) & (addrs != 0)
return g, r, ok
ga, ra, oka = read(man.u)
gb, rb, okb = read(np.where(man.m != 0, man.m, man.u))
t1 = man.m != 0
is_r = man.col == "R"
# Infinium II: M green, U red at address A; Infinium I: both addresses in the probe's channel
m = np.where(t1, np.where(is_r, rb, gb), ga)
u = np.where(t1, np.where(is_r, ra, ga), ra)
oob_m = np.where(t1, np.where(is_r, gb, rb), np.nan)
oob_u = np.where(t1, np.where(is_r, ga, ra), np.nan)
masked = ~(oka & np.where(t1, okb, True)) | (t1 & ~np.isin(man.col, ("R", "G")))
for a in (m, u, oob_m, oob_u):
a[masked] = np.nan
caddr = np.array([c.address for c in man.controls], dtype=np.int64)
cg, cr, cok = read(caddr)
cg[~cok] = np.nan
cr[~cok] = np.nan
return MethylSignals(m, u, oob_m, oob_u, masked, man.col, cg, cr, [c.type for c in man.controls])
def _finite(a: F64) -> F64:
return a[np.isfinite(a)]
def background(s: MethylSignals) -> tuple[F64, F64]:
"""Out-of-band background per channel (green, red): Infinium I OOB signal + negative controls."""
neg = np.array([t.casefold() == "negative" for t in s.ctrl_type], dtype=np.bool_)
red_i, grn_i = s.col == "R", s.col == "G"
oob_g = np.concatenate([s.oob_m[red_i], s.oob_u[red_i], s.ctrl_green[neg]])
oob_r = np.concatenate([s.oob_m[grn_i], s.oob_u[grn_i], s.ctrl_red[neg]])
return _finite(oob_g), _finite(oob_r)
def in_band(s: MethylSignals) -> tuple[F64, F64]:
"""In-band foreground per channel (green, red): Infinium I M/U in their channel, Infinium II M green / U red."""
red_i, grn_i, t2 = s.col == "R", s.col == "G", s.col == ""
g = np.concatenate([s.m[grn_i], s.u[grn_i], s.m[t2]])
r = np.concatenate([s.m[red_i], s.u[red_i], s.u[t2]])
return _finite(g), _finite(r)
# ------------------------------------------------------------------------------------------------ detection
def _ecdf(ref: F64): # type: ignore[no-untyped-def]
srt = np.sort(ref)
n = len(srt)
def f(x: F64) -> F64:
if n == 0:
return np.zeros(len(x))
return np.searchsorted(srt, x, side="right") / n # R ecdf: P(X <= x)
return f
def detection_p(s: MethylSignals) -> F64:
"""pOOBAH detection p-value per probe; 1 where masked."""
bg_g, bg_r = background(s)
fg, fr = _ecdf(bg_g), _ecdf(bg_r)
red_i, t2 = s.col == "R", s.col == ""
m, u = np.nan_to_num(s.m, nan=0.0), np.nan_to_num(s.u, nan=0.0)
fm = np.where(red_i, fr(m), fg(m)) # Infinium II M is green, Infinium I green M is green
fu = np.where(red_i | t2, fr(u), fg(u)) # Infinium II U is red
p = 1.0 - np.maximum(fm, fu)
p[s.masked] = 1.0
return np.asarray(p, dtype=np.float64)
# ------------------------------------------------------------------------------------------------ noob
def huber(y: F64, k: float = 1.5, tol: float = 1e-6) -> tuple[float, float]:
"""MASS::huber: Huber M-estimator of location with MAD scale (returns the location before the last update)."""
y = _finite(np.asarray(y, dtype=np.float64))
n = len(y)
if n == 0:
raise ValueError("huber: no finite values")
mu = float(np.median(y))
s = float(1.4826 * np.median(np.abs(y - mu)))
if s == 0:
raise ValueError("huber: cannot estimate scale, MAD is zero")
for _ in range(1000):
yy = np.minimum(np.maximum(mu - k * s, y), mu + k * s)
mu1 = float(yy.sum() / n)
if abs(mu - mu1) < tol * s:
break
mu = mu1
return mu, s
_erfc = np.frompyfunc(math.erfc, 1, 1)
def _mills(z: F64) -> F64:
"""φ(z)/Φ(z) (inverse Mills ratio), stable far into the lower tail."""
z = np.asarray(z, dtype=np.float64)
out = np.empty_like(z)
lo = z < -30.0
zz = z[~lo]
phi = np.exp(-0.5 * zz * zz) / math.sqrt(2.0 * math.pi)
big_phi = 0.5 * np.asarray(_erfc(-zz / math.sqrt(2.0)), dtype=np.float64)
out[~lo] = phi / big_phi
zl = z[lo]
r = 1.0 / (zl * zl)
out[lo] = -zl / (1.0 - r + 3.0 * r * r - 15.0 * r**3 + 105.0 * r**4)
return out
def normexp_signal(mu: float, sigma: float, alpha: float, x: F64) -> F64:
"""limma ``normexp.signal`` (formula): E[signal | observed x] under x = N(µ, σ²) + Exp(α)."""
if alpha <= 0 or sigma <= 0:
raise ValueError("normexp: alpha and sigma must be positive")
mu_sf = x - mu - sigma * sigma / alpha
sig = mu_sf + sigma * _mills(mu_sf / sigma)
ok = np.isfinite(sig)
if (sig[ok] < 0).any():
sig[ok] = np.maximum(sig[ok], 1e-6)
return sig
@dataclass
class NoobFit:
mu: float
sigma: float
alpha: float
def noob_fit(ib: F64, oob: F64) -> NoobFit:
ib = np.where(ib < 1, 1.0, ib)
mu, s = huber(oob)
return NoobFit(mu, s, max(huber(ib)[0] - mu, 10.0))
def noob(s: MethylSignals, offset: float = NOOB_OFFSET) -> tuple[MethylSignals, NoobFit, NoobFit]:
"""Background-corrected copy of ``s`` (probe and control signals); returns the green and red fits."""
bg_g, bg_r = background(s)
ib_g, ib_r = in_band(s)
fit_g, fit_r = noob_fit(ib_g, bg_g), noob_fit(ib_r, bg_r)
def corr(x: F64, f: NoobFit) -> F64:
y = np.where(np.isfinite(x), np.maximum(x, 1.0), np.nan)
out = np.full_like(y, np.nan)
ok = np.isfinite(y)
out[ok] = normexp_signal(f.mu, f.sigma, f.alpha, y[ok]) + offset
return out
red_i, t2 = s.col == "R", s.col == ""
fm = np.where(red_i, 1, 0) # channel of M: red for Infinium I red, else green
fu = np.where(red_i | t2, 1, 0)
m = np.where(fm == 1, corr(s.m, fit_r), corr(s.m, fit_g))
u = np.where(fu == 1, corr(s.u, fit_r), corr(s.u, fit_g))
out = MethylSignals(
m, u, s.oob_m, s.oob_u, s.masked, s.col, corr(s.ctrl_green, fit_g), corr(s.ctrl_red, fit_r), s.ctrl_type
)
return out, fit_g, fit_r
# ------------------------------------------------------------------------------------------------ dye bias
def _scale(s: MethylSignals, fg: float, fr: float) -> MethylSignals:
red_i, t2 = s.col == "R", s.col == ""
m = s.m * np.where(red_i, fr, fg)
u = s.u * np.where(red_i | t2, fr, fg)
return MethylSignals(m, u, s.oob_m, s.oob_u, s.masked, s.col, s.ctrl_green * fg, s.ctrl_red * fr, s.ctrl_type)
def dye_bias_linear(s: MethylSignals) -> MethylSignals:
"""Scale the channels so the normalization-control means (NORM_A/T red, NORM_C/G green) meet at their average."""
t = np.array([x.upper() for x in s.ctrl_type])
red = _finite(s.ctrl_red[np.isin(t, ("NORM_A", "NORM_T"))])
grn = _finite(s.ctrl_green[np.isin(t, ("NORM_C", "NORM_G"))])
if not len(red) or not len(grn):
raise ValueError("dye bias: normalization controls missing")
mr, mg = float(red.mean()), float(grn.mean())
ref = (mr + mg) / 2.0
return _scale(s, ref / mg, ref / mr)
def dye_bias_nonlinear(s: MethylSignals) -> MethylSignals:
"""Quantile-match the Infinium I red and green in-band signals to their average quantile function; every signal
of a channel is mapped through that channel's curve (linear extrapolation beyond the Infinium I range)."""
red_i, grn_i, t2 = s.col == "R", s.col == "G", s.col == ""
r_ref = np.sort(_finite(np.concatenate([s.m[red_i], s.u[red_i]])))
g_ref = np.sort(_finite(np.concatenate([s.m[grn_i], s.u[grn_i]])))
if len(r_ref) < 2 or len(g_ref) < 2:
raise ValueError("dye bias: too few Infinium I probes")
q = np.linspace(0, 1, 1001)
rq, gq = np.quantile(r_ref, q), np.quantile(g_ref, q)
target = (rq + gq) / 2.0
def mapper(src_q: F64): # type: ignore[no-untyped-def]
lo_slope = target[0] / src_q[0] if src_q[0] > 0 else 1.0
hi_slope = target[-1] / src_q[-1] if src_q[-1] > 0 else 1.0
def f(x: F64) -> F64:
y = np.interp(x, src_q, target)
y = np.where(x < src_q[0], x * lo_slope, y)
return np.where(x > src_q[-1], x * hi_slope, y)
return f
fr, fg = mapper(rq), mapper(gq)
m = np.where(red_i, fr(s.m), fg(s.m))
u = np.where(red_i | t2, fr(s.u), fg(s.u))
return MethylSignals(m, u, s.oob_m, s.oob_u, s.masked, s.col, fg(s.ctrl_green), fr(s.ctrl_red), s.ctrl_type)
# ------------------------------------------------------------------------------------------------ beta
def beta_values(s: MethylSignals, offset: float = 0.0) -> F64:
m = np.maximum(s.m, 1.0)
u = np.maximum(s.u, 1.0)
return m / (m + u + offset)
def m_values(beta: F64) -> F64:
b = np.clip(beta, 1e-6, 1 - 1e-6)
return np.log2(b / (1 - b))
def average_signals(s: MethylSignals, analytical: Mask) -> tuple[float, float]:
"""MET-012: mean green and red in-band signal over analytical probes."""
red_i, grn_i, t2 = s.col == "R", s.col == "G", s.col == ""
a = analytical
g = _finite(np.concatenate([s.m[grn_i & a], s.u[grn_i & a], s.m[t2 & a]]))
r = _finite(np.concatenate([s.m[red_i & a], s.u[red_i & a], s.u[t2 & a]]))
return (float(g.mean()) if len(g) else math.nan, float(r.mean()) if len(r) else math.nan)
+234
View File
@@ -0,0 +1,234 @@
"""Methylation control QC (spec MET-001 … MET-018)."""
from __future__ import annotations
import gzip
import math
from pathlib import Path
import numpy as np
import pytest
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.core.errors import FormatError
from simurg.formats.methyl_manifest import read_methyl_manifest, write_probe_tables
from simurg.qc.controls import ControlBead, canonical_id, compute_control_metrics
from simurg.signal import methyl as sig
from simurg.synth.files import SynthIdat, encode_idat
HEAD = "Illumina, Inc.,,,\n[Heading],,,\nDescriptor File Name,SYNTH-METH_A1.csv,,\n[Assay],,,\n"
COLS = "IlmnID,Name,AddressA_ID,AlleleA_ProbeSeq,AddressB_ID,AlleleB_ProbeSeq,Infinium_Design_Type,Color_Channel\n"
CONTROLS = [ # (type, colour, name); one bead each
("STAINING", "Red", "DNP (High)"), ("STAINING", "Purple", "DNP (Bkg)"), ("STAINING", "Green", "Biotin (High)"),
("STAINING", "Blue", "Biotin (Bkg)"), ("EXTENSION", "Red", "Extension (A)"), ("EXTENSION", "Purple",
"Extension (T)"), ("EXTENSION", "Green", "Extension (C)"), ("EXTENSION", "Blue", "Extension (G)"),
("HYBRIDIZATION", "Green", "Hyb (High)"), ("HYBRIDIZATION", "Blue", "Hyb (Medium)"), ("HYBRIDIZATION", "Black",
"Hyb (Low)"), ("TARGET REMOVAL", "Green", "Target Removal 1"), ("TARGET REMOVAL", "Blue", "Target Removal 2"),
("RESTORATION", "Green", "Restore"), ("NON-POLYMORPHIC", "Red", "NP (A)"), ("NON-POLYMORPHIC", "Purple", "NP (T)"),
("NON-POLYMORPHIC", "Green", "NP (C)"), ("NON-POLYMORPHIC", "Blue", "NP (G)"),
*[("SPECIFICITY I", "Green", f"GT Mismatch {i} (PM)") for i in range(1, 7)],
*[("SPECIFICITY I", "Blue", f"GT Mismatch {i} (MM)") for i in range(1, 7)],
*[("SPECIFICITY II", "Red", f"Specificity {i}") for i in range(1, 4)],
*[("BISULFITE CONVERSION I", "Green", f"BS Conversion I-C{i}") for i in range(1, 6)],
*[("BISULFITE CONVERSION I", "Blue", f"BS Conversion I-U{i}") for i in range(1, 6)],
*[("BISULFITE CONVERSION II", "Red", f"BS Conversion II-{i}") for i in range(1, 5)],
*[("NEGATIVE", "Red", f"Negative {i}") for i in range(1, 41)],
*[(f"NORM_{b}", "Red", f"Norm_{b}{i}") for b in "ACGT" for i in range(1, 6)],
] # fmt: skip
N_T1R, N_T1G, N_T2 = 60, 60, 180
def _manifest(tmp: Path, *, controls: list[tuple[str, str, str]] | None = None, extra_field: bool = False) -> Path:
lines = [HEAD, COLS]
addr = 1000
for i in range(N_T1R + N_T1G + N_T2):
if i < N_T1R + N_T1G:
ch = "Red" if i < N_T1R else "Grn"
lines.append(f"cg{i:08d},cg{i:08d},{addr},ACGT,{addr + 1},ACGT,I,{ch}\n")
addr += 2
else:
lines.append(f"cg{i:08d},cg{i:08d},{addr},ACGT,,,II,\n")
addr += 1
lines.append("rs0000001,rs0000001,9001,ACGT,,,II,\n")
lines.append("ch.1.1,ch.1.1,9002,ACGT,,,II,\n")
lines.append("[Controls],,,\n")
for k, (t, c, n) in enumerate(controls if controls is not None else CONTROLS):
tail = ",AVG" if extra_field and t == "NEGATIVE" else ""
lines.append(f"{20000 + k},{t},{c},{n}{tail},,,,\n")
p = tmp / "meth.csv"
p.write_text("".join(lines))
return p
def _idats(folder: Path, man_path: Path, seed: int, barcode: str) -> None:
man = read_methyl_manifest(man_path)
rng = np.random.default_rng(seed)
addrs = np.unique(np.concatenate([man.u, man.m[man.m > 0], [c.address for c in man.controls]])).astype(np.int32)
n = len(addrs)
for ch in ("Grn", "Red"):
mean = rng.gamma(2.0, 2500.0, n).clip(50, 60000).astype(np.uint16)
folder.mkdir(parents=True, exist_ok=True)
(folder / f"{barcode}_R01C01_{ch}.idat").write_bytes(
encode_idat(SynthIdat(addrs, mean, n_beads=np.full(n, 8, np.uint8), barcode=barcode))
)
# ------------------------------------------------------------------------------------------------ manifest
def test_manifest_parse_and_tables(tmp_path: Path) -> None: # MET-002, MET-003
man = read_methyl_manifest(_manifest(tmp_path, extra_field=True))
assert man.num_probes == N_T1R + N_T1G + N_T2 + 2
assert int(man.type_i.sum()) == N_T1R + N_T1G
assert (man.col[:N_T1R] == "R").all() and (man.col[N_T1R : N_T1R + N_T1G] == "G").all()
assert (man.col[N_T1R + N_T1G :] == "").all()
assert int(man.analytical().sum()) == N_T1R + N_T1G + N_T2 + 1 # cg + rs, not ch
assert len(man.controls) == len(CONTROLS) and man.controls[0].probe_id == "ctl_20000"
probes, ctl = write_probe_tables(man, tmp_path / "t")
lines = probes.read_text().splitlines()
assert lines[0] == "probe_id\tu\tm\tcol"
assert lines[1] == "cg00000000\t1000\t1001\tR"
assert lines[N_T1R + N_T1G + 1].endswith("\tNA\tNA")
assert ctl.read_text().splitlines()[1] == "20000\tSTAINING\tRed\tDNP (High)\tctl_20000"
@pytest.mark.parametrize(
("text", "code"),
[
("[Heading],\nx,y\n[Controls]\n1,NEGATIVE,Red,N1\n", "E_METH_NO_ASSAY"),
("[Assay]\n" + COLS + "cg1,cg1,1,A,,,II,\n", "E_METH_NO_CONTROLS"),
("[Assay]\nIlmnID,Name,AddressA_ID\ncg1,cg1,1\n[Controls]\n1,NEGATIVE,Red,N1\n", "E_METH_MISSING_COLUMNS"),
("[Assay]\n" + COLS + "cg1,cg1,1,A,,,II,\n[Controls]\n1,NEGATIVE,Red\n", "E_METH_CONTROLS_FORMAT"),
],
)
def test_manifest_errors(tmp_path: Path, text: str, code: str) -> None:
p = tmp_path / "bad.csv"
p.write_text(text)
with pytest.raises(FormatError) as e:
read_methyl_manifest(p)
assert e.value.code == code
# ------------------------------------------------------------------------------------------------ numerics
def test_huber_matches_mass() -> None:
# MASS::huber(c(1, 2, 3, 4, 100)): mu is the fixed point of mu = (1 + 2 + 3 + 4 + (mu + 1.5 * s)) / 5
mu, s = sig.huber(np.array([1.0, 2.0, 3.0, 4.0, 100.0]))
assert s == pytest.approx(1.4826)
assert mu == pytest.approx((10 + 1.5 * 1.4826) / 4, abs=1e-5)
with pytest.raises(ValueError):
sig.huber(np.ones(5))
def test_mills_ratio() -> None:
assert sig._mills(np.array([0.0]))[0] == pytest.approx(math.sqrt(2 / math.pi))
lo, hi = sig._mills(np.array([-30.0001, -29.9999]))
assert lo == pytest.approx(hi, rel=1e-5)
z = np.array([-5.0, -1.0, 2.0])
phi = np.exp(-z * z / 2) / math.sqrt(2 * math.pi)
big = np.array([0.5 * math.erfc(-v / math.sqrt(2)) for v in z])
assert np.allclose(sig._mills(z), phi / big, rtol=1e-12)
def test_normexp_signal() -> None:
x = np.array([1.0, 500.0, 5000.0, 60000.0])
out = sig.normexp_signal(400.0, 150.0, 3000.0, x)
assert (out > 0).all() and (np.diff(out) > 0).all()
assert out[-1] == pytest.approx(60000.0 - 400.0 - 150.0**2 / 3000.0, rel=1e-9)
def test_detection_and_beta() -> None: # MET-010, MET-011
s = sig.MethylSignals(
m=np.array([5000.0, 100.0, 3000.0, np.nan]),
u=np.array([100.0, 90.0, 4000.0, np.nan]),
oob_m=np.array([200.0, 150.0, np.nan, np.nan]),
oob_u=np.array([250.0, 300.0, np.nan, np.nan]),
masked=np.array([False, False, False, True]),
col=np.array(["R", "G", "", ""]),
ctrl_green=np.array([120.0, 130.0]),
ctrl_red=np.array([110.0, 140.0]),
ctrl_type=["NEGATIVE", "NEGATIVE"],
)
p = sig.detection_p(s)
assert p[0] == 0.0 # far above the red background
assert p[1] > 0.5 # at the green background
assert p[3] == 1.0 # masked
b = sig.beta_values(s)
assert b[0] == pytest.approx(5000 / 5100)
assert sig.beta_values(s, 100.0)[0] == pytest.approx(5000 / 5200)
assert np.isnan(b[3])
mv = sig.m_values(np.array([0.0, 0.5, 1.0]))
assert mv[1] == 0.0 and mv[0] == pytest.approx(math.log2(1e-6 / (1 - 1e-6)))
def test_methylation_metrics(tmp_path: Path) -> None: # MET-006
man = read_methyl_manifest(_manifest(tmp_path))
beads = [ControlBead(c.type, c.color_channel, c.name, c.address, canonical_id(c.name)) for c in man.controls]
assert all(b.canonical for b in beads if not b.category.startswith(("NEGATIVE", "NORM")))
g = np.full(len(beads), 1000.0)
r = np.full(len(beads), 1000.0)
idx = {b.name: i for i, b in enumerate(beads)}
for nm, v in (("BS Conversion I-C3", 8000.0), ("BS Conversion I-U3", 400.0), ("BS Conversion I-U5", 500.0)):
r[idx[nm]] = v
r[idx["BS Conversion I-C4"]] = r[idx["BS Conversion I-C5"]] = 9000.0
q = compute_control_metrics(beads, g, r, "methylation", 3000.0)
assert set(q.values) == {m for m in q.values} and len(q.values) == 21
assert q.values["bs_conv_1_red"] == pytest.approx(8000 / 1000) # max(U3..U5) = 1000 (U4)
assert q.values["target_removal_2"] == pytest.approx((1000 + 3000) / 1000)
q2 = compute_control_metrics(beads, g, r, "methylation", 3000.0, "U4-U6")
assert q2.missing.get("bs_conv_1_red") == ["bs1_U6"]
# ------------------------------------------------------------------------------------------------ CLI
def test_methyl_qc_end_to_end(tmp_path: Path) -> None: # MET-013
man = _manifest(tmp_path)
for k in range(3):
_idats(tmp_path / "idats" / f"20400000000{k}", man, k, f"20400000000{k}")
out = tmp_path / "out"
r = CliRunner().invoke(app, ["methyl", "qc", "--manifest", str(man), "--idats", str(tmp_path / "idats"),
"--out", str(out), "--threads", "1", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
for f in ("methyl_qc_summary.tsv", "methyl_qc_summary.xlsx", "methyl_qc_summary.html", "pcs.tsv.gz",
"probes.tsv", "controls.tsv", "controls_raw.tsv", "controls_qc.tsv", "run_manifest.json"): # fmt: skip
assert (out / f).exists(), f
rows = [ln.split("\t") for ln in (out / "methyl_qc_summary.tsv").read_text().splitlines()]
head = rows[0]
assert head[:3] == ["sentrix_id", "sentrix_position", "sample_id"] and len(rows) == 4
for c in ("bs_conv_2_bg", "avg_green_norm", "n_probes", "prop_probes_passing", "pass_qc", "fail_codes"):
assert c in head
assert {r_[head.index("n_probes")] for r_ in rows[1:]} == {str(N_T1R + N_T1G + N_T2 + 1)}
sid = rows[1][2]
with gzip.open(out / "samples" / sid / f"{sid}_cgs.tsv.gz", "rt") as fh:
cgs = fh.read().splitlines()
assert cgs[0] == "probe_id\tbeta\tm_value\tdetection_p" and len(cgs) == N_T1R + N_T1G + N_T2 + 3
with gzip.open(out / "pcs.tsv.gz", "rt") as fh:
assert len(fh.read().splitlines()) == 4
# the folder is a valid qc report dataset (MET-014)
r = CliRunner().invoke(app, ["qc", "report", "--data", str(out), "--out", str(tmp_path / "rep"), "--quiet"])
assert r.exit_code == 0, r.output
def test_methyl_qc_missing_types_and_no_idats(tmp_path: Path) -> None: # MET-009, MET-004
man = _manifest(tmp_path, controls=[c for c in CONTROLS if c[0] != "RESTORATION"])
_idats(tmp_path / "idats" / "204000000009", man, 9, "204000000009")
out = tmp_path / "out"
r = CliRunner().invoke(app, ["methyl", "qc", "--manifest", str(man), "--idats", str(tmp_path / "idats"),
"--out", str(out), "--threads", "1"]) # fmt: skip
assert r.exit_code == 0, r.output
assert "W_METH_MISSING_CONTROL_NAMES" in r.output
assert "restoration" in (out / "methyl_qc_summary.tsv").read_text().splitlines()[1]
(tmp_path / "empty").mkdir()
r = CliRunner().invoke(app, ["methyl", "qc", "--manifest", str(man), "--idats", str(tmp_path / "empty"),
"--out", str(tmp_path / "o2")]) # fmt: skip
assert r.exit_code == 2 and "E_METH_NO_IDATS" in r.output
def test_convert_manifest_and_qc_controls(tmp_path: Path) -> None: # MET-003, QC-012
man = _manifest(tmp_path)
r = CliRunner().invoke(app, ["methyl", "convert-manifest", "--manifest", str(man), "--out", str(tmp_path / "c")])
assert r.exit_code == 0, r.output
assert (tmp_path / "c" / "probes.tsv").exists()
_idats(tmp_path / "idats" / "204000000001", man, 1, "204000000001")
r = CliRunner().invoke(app, ["qc", "controls", "--array-type", "methylation", "--csv", str(man), "--idats",
str(tmp_path / "idats"), "--out", str(tmp_path / "q"), "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
qc = (tmp_path / "q" / "controls_qc.tsv").read_text().splitlines()
assert "bs_conv_2_bg" in qc[0] and "stringency" not in qc[0] and qc[1].split("\t")[1] == "methylation"
+3 -4
View File
@@ -167,10 +167,9 @@ def test_absent_addresses_are_skipped() -> None: # QC-008, K-021: two bead-pool
assert q.missing["staining_green"] == ["biotin_bkg"] assert q.missing["staining_green"] == ["biotin_bkg"]
def test_methylation_is_v2() -> None: def test_methylation_metric_set() -> None: # QC-006: methylation metrics are computed (v2); missing ones are NaN
with pytest.raises(SimurgError) as e: q = compute_control_metrics(_beads(), np.ones(21), np.ones(21), "methylation")
compute_control_metrics(_beads(), np.ones(21), np.ones(21), "methylation") assert len(q.values) == 21 and "stringency" not in q.values and "bs_conv_2" in q.missing
assert e.value.code == "E_QC_METHYLATION"
# ------------------------------------------------------------------------------------------------ thresholds # ------------------------------------------------------------------------------------------------ thresholds