Files
PlantAI-dev/apps/cli/run_wgs_bam_qc.py
T
2026-08-03 18:42:47 +08:00

566 lines
19 KiB
Python

#!/usr/bin/env python3
"""Run Stage 05 WGS BAM quality metrics from authoritative Manifests."""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import uuid
from typing import Any
from gfa_core.wgs_scientific_identity import build_wgs_analysis_id, sha256_file
from gfa_core.wgs_upstream_manifest import resolve_wgs_artifacts
STAGE = "wgs_bam_qc"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Measure coverage and alignment counts for authoritative WGS BAMs"
)
parser.add_argument("--workspace", required=True)
parser.add_argument("--job-id", required=True)
parser.add_argument("--prefix", required=True)
parser.add_argument("--markdup-manifest", required=True)
parser.add_argument("--index-manifest", required=True)
parser.add_argument("--samtools", required=True)
parser.add_argument("--coverage-threshold", type=int, default=1, choices=(1,))
parser.add_argument("--threads", required=True, type=int)
parser.add_argument("--max-jobs", required=True, type=int)
return parser
def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
os.replace(temporary, path)
def _safe_component(value: str, label: str) -> None:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", value or ""):
raise ValueError(f"{label} must be a non-empty safe path component")
def _authority_header(
path: str | Path,
*,
stage: str,
workspace: Path,
prefix: str,
job_id: str,
) -> dict[str, Any]:
try:
manifest = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError(f"{stage} authority Manifest is unreadable") from exc
if (
not isinstance(manifest, dict)
or manifest.get("stage") != stage
or manifest.get("status") != "success"
):
raise ValueError(f"{stage} authority Manifest is not a successful producer")
if (
manifest.get("prefix") != prefix
or manifest.get("job_id") != job_id
or manifest.get("workspace") != str(workspace)
):
raise ValueError(f"{stage} authority Manifest identity mismatch")
return manifest
def _checked(command: list[str], name: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode:
raise RuntimeError(
f"{name} failed with exit code {result.returncode}: {result.stderr.strip()}"
)
return result
def _tool_version(samtools: str) -> str:
result = _checked([samtools, "--version"], "samtools version check")
lines = result.stdout.strip().splitlines()
if not lines:
raise RuntimeError("samtools version check produced no version")
return lines[0]
def _reference_lengths(path: Path) -> dict[str, int]:
lengths: dict[str, int] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
raise RuntimeError("reference FAI is unreadable") from exc
for line_number, line in enumerate(lines, 1):
fields = line.split("\t")
if len(fields) < 2 or not fields[0]:
raise RuntimeError(f"reference FAI line {line_number} is invalid")
try:
length = int(fields[1])
except ValueError as exc:
raise RuntimeError(
f"reference FAI line {line_number} has invalid length"
) from exc
if length <= 0 or fields[0] in lengths:
raise RuntimeError(
f"reference FAI line {line_number} has duplicate/invalid contig"
)
lengths[fields[0]] = length
if not lengths:
raise RuntimeError("reference FAI contains no contigs")
return lengths
def _parse_depth(
path: Path, sample_id: str, lengths: dict[str, int], threshold: int
) -> dict[str, int | float | str]:
depth_sum = 0
bases_ge_threshold = 0
observed_positions: set[tuple[str, int]] = set()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
raise RuntimeError(f"sample {sample_id}: samtools depth output is unreadable") from exc
for line_number, line in enumerate(lines, 1):
fields = line.split("\t")
if len(fields) != 3 or fields[0] not in lengths:
raise RuntimeError(
f"sample {sample_id}: invalid depth row {line_number}"
)
try:
position, depth = int(fields[1]), int(fields[2])
except ValueError as exc:
raise RuntimeError(
f"sample {sample_id}: invalid depth row {line_number}"
) from exc
identity = (fields[0], position)
if (
position < 1
or position > lengths[fields[0]]
or depth < 0
or identity in observed_positions
):
raise RuntimeError(
f"sample {sample_id}: invalid depth row {line_number}"
)
observed_positions.add(identity)
depth_sum += depth
if depth >= threshold:
bases_ge_threshold += 1
total_reference_bases = sum(lengths.values())
return {
"sample_id": sample_id,
"total_reference_bases": total_reference_bases,
"depth_sum": depth_sum,
"mean_depth": depth_sum / total_reference_bases,
"bases_ge_1x": bases_ge_threshold,
"coverage_ge_1x": bases_ge_threshold / total_reference_bases,
}
def _run_sample(
sample_id: str,
input_bam: Path,
bundle: Path,
args: argparse.Namespace,
lengths: dict[str, int],
) -> tuple[dict[str, Path], dict[str, int | float | str]]:
base = f"{args.prefix}.{STAGE}.{sample_id}"
flagstat_path = bundle / f"{base}.flagstat.txt"
depth_summary_path = bundle / f"{base}.depth.tsv"
depth_raw = bundle / f".{base}.depth.raw.tsv"
flagstat = _checked(
[args.samtools, "flagstat", "-@", str(args.threads), str(input_bam)],
f"sample {sample_id} flagstat",
)
if " in total " not in f" {flagstat.stdout} ":
raise RuntimeError(f"sample {sample_id}: flagstat content is invalid")
flagstat_path.write_text(flagstat.stdout, encoding="utf-8")
with depth_raw.open("w", encoding="utf-8") as output:
depth = subprocess.run(
[
args.samtools,
"depth",
"-d",
"0",
"-@",
str(args.threads),
str(input_bam),
],
text=True,
stdout=output,
stderr=subprocess.PIPE,
check=False,
)
if depth.returncode:
raise RuntimeError(
f"sample {sample_id} depth failed with exit code "
f"{depth.returncode}: {depth.stderr.strip()}"
)
metrics = _parse_depth(
depth_raw, sample_id, lengths, args.coverage_threshold
)
depth_raw.unlink()
columns = (
"sample_id",
"total_reference_bases",
"depth_sum",
"mean_depth",
"bases_ge_1x",
"coverage_ge_1x",
)
depth_summary_path.write_text(
"\t".join(columns)
+ "\n"
+ "\t".join(
(
str(metrics["sample_id"]),
str(metrics["total_reference_bases"]),
str(metrics["depth_sum"]),
f"{float(metrics['mean_depth']):.12g}",
str(metrics["bases_ge_1x"]),
f"{float(metrics['coverage_ge_1x']):.12g}",
)
)
+ "\n",
encoding="utf-8",
)
return (
{
"bam_flagstat": flagstat_path,
"bam_depth_summary": depth_summary_path,
},
metrics,
)
def _artifact(
workspace: Path,
source: Path,
destination: Path,
role: str,
sample_id: str = "",
) -> dict[str, Any]:
size = source.stat().st_size
record = {
"artifact": role,
"role": role,
"path": str(destination.relative_to(workspace)),
"full_path": str(destination.resolve()),
"type": source.suffix.lstrip(".") or "file",
"size": size,
"size_bytes": size,
"sha256": sha256_file(source),
}
if sample_id:
record["sample_id"] = sample_id
return record
def _publish(
bundle: Path,
destination: Path,
manifest_work: Path,
authority: Path,
started_at: str,
) -> bool:
lock_path = authority.parent.parent / ".locks" / f"{authority.stem}.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
if authority.is_file():
try:
current = json.loads(authority.read_text(encoding="utf-8"))
if (
current.get("status") == "success"
and str(current.get("start_time") or "") > started_at
):
return False
except Exception:
pass
destination.parent.mkdir(parents=True, exist_ok=True)
os.replace(bundle, destination)
os.replace(manifest_work, authority)
return True
def _failure(
args: argparse.Namespace, run_id: str, started_at: str, error: str
) -> dict[str, Any]:
return {
"manifest_version": "1.0",
"stage": STAGE,
"status": "failed",
"prefix": args.prefix,
"job_id": args.job_id,
"run_id": run_id,
"start_time": started_at,
"artifacts": {},
"errors": [error],
}
def _publish_failure(
authority: Path,
args: argparse.Namespace,
run_id: str,
started_at: str,
error: str,
) -> None:
lock_path = authority.parent.parent / ".locks" / f"{authority.stem}.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
failure_path = (
authority
if not authority.exists()
else authority.parent / f"{args.prefix}.{STAGE}.failed.{run_id}.manifest.json"
)
_atomic_json(failure_path, _failure(args, run_id, started_at, error))
def run(args: argparse.Namespace) -> int:
workspace = Path(args.workspace).resolve()
run_id = uuid.uuid4().hex
started_at = datetime.now(timezone.utc).isoformat()
final = workspace / "final"
final.mkdir(parents=True, exist_ok=True)
try:
_safe_component(args.prefix, "prefix")
except ValueError as exc:
_atomic_json(
final / f"{STAGE}.failed.{run_id}.manifest.json",
_failure(args, run_id, started_at, str(exc)),
)
print(f"{STAGE} failed: {exc}", file=sys.stderr)
return 1
authority = final / f"{args.prefix}.{STAGE}.manifest.json"
try:
if args.threads < 1 or args.max_jobs < 1:
raise ValueError("threads and max-jobs must be positive")
markdup_header = _authority_header(
args.markdup_manifest,
stage="wgs_markdup",
workspace=workspace,
prefix=args.prefix,
job_id=args.job_id,
)
index_header = _authority_header(
args.index_manifest,
stage="wgs_index",
workspace=workspace,
prefix=args.prefix,
job_id=args.job_id,
)
sample_set_id = str(markdup_header.get("wgs_sample_set_id") or "")
reference_id = str(markdup_header.get("wgs_reference_id") or "")
markdup_analysis_id = str(markdup_header.get("wgs_analysis_id") or "")
index_analysis_id = str(index_header.get("wgs_analysis_id") or "")
if not all(
(sample_set_id, reference_id, markdup_analysis_id, index_analysis_id)
):
raise ValueError("upstream scientific identities are missing")
if index_header.get("wgs_reference_id") != reference_id:
raise ValueError("upstream reference identities do not match")
markdup = resolve_wgs_artifacts(
args.markdup_manifest,
"wgs_markdup",
("markdup_bam", "markdup_bam_index"),
sample_set_id,
reference_id,
markdup_analysis_id,
)
index = resolve_wgs_artifacts(
args.index_manifest,
"wgs_index",
("reference_fai",),
expected_reference_id=reference_id,
expected_analysis_id=index_analysis_id,
)
if len(index["reference_fai"]) != 1:
raise RuntimeError("authoritative index must contain one reference FAI")
bam_by_sample = {item.sample_id: item for item in markdup["markdup_bam"]}
bai_by_sample = {
item.sample_id: item for item in markdup["markdup_bam_index"]
}
expected_samples = set(bam_by_sample)
if not expected_samples or expected_samples != set(bai_by_sample):
raise RuntimeError("authoritative markdup input sample sets do not match")
lengths = _reference_lengths(index["reference_fai"][0].path)
version = _tool_version(args.samtools)
parameters = {
"samtools": args.samtools,
"coverage_threshold": args.coverage_threshold,
"metric_name": "coverage_ge_1x",
"threads": args.threads,
"max_jobs": args.max_jobs,
}
consumed = [
item.sha256
for group in (markdup, index)
for values in group.values()
for item in values
]
analysis_id = build_wgs_analysis_id(
STAGE,
consumed,
parameters,
{"samtools": version},
method="samtools_flagstat_depth",
)
attempt = workspace / ".attempts" / run_id
bundle = attempt / "bundle"
bundle.mkdir(parents=True, exist_ok=False)
completed: dict[str, dict[str, Path]] = {}
sample_metrics: dict[str, dict[str, int | float | str]] = {}
failures: list[str] = []
with ThreadPoolExecutor(max_workers=args.max_jobs) as pool:
futures = {
pool.submit(
_run_sample,
sample,
bam_by_sample[sample].path,
bundle,
args,
lengths,
): sample
for sample in sorted(expected_samples)
}
for future in as_completed(futures):
sample = futures[future]
try:
completed[sample], sample_metrics[sample] = future.result()
except Exception as exc:
failures.append(str(exc))
if failures or set(completed) != expected_samples:
raise RuntimeError(
"; ".join(failures)
or "BAM QC input/output sample-set conservation failed"
)
summary = bundle / f"{args.prefix}.{STAGE}.cohort.summary.tsv"
summary_columns = (
"sample_id",
"total_reference_bases",
"depth_sum",
"mean_depth",
"bases_ge_1x",
"coverage_ge_1x",
)
summary.write_text(
"\t".join(summary_columns)
+ "\n"
+ "".join(
"\t".join(
(
sample,
str(sample_metrics[sample]["total_reference_bases"]),
str(sample_metrics[sample]["depth_sum"]),
f"{float(sample_metrics[sample]['mean_depth']):.12g}",
str(sample_metrics[sample]["bases_ge_1x"]),
f"{float(sample_metrics[sample]['coverage_ge_1x']):.12g}",
)
)
+ "\n"
for sample in sorted(sample_metrics)
),
encoding="utf-8",
)
cohort_coverage = sum(
float(value["coverage_ge_1x"]) for value in sample_metrics.values()
) / len(sample_metrics)
report = bundle / f"{args.prefix}.{STAGE}.cohort.report.md"
report.write_text(
"# WGS BAM QC\n\n"
f"- Samples: {len(sample_metrics)}\n"
f"- Reference bases per sample: {sum(lengths.values())}\n"
f"- coverage_ge_1x: {cohort_coverage:.12g}\n"
"- Coverage denominator includes every reference base, including "
"zero-coverage positions.\n"
"- No sample was excluded based on coverage or depth.\n",
encoding="utf-8",
)
destination = final / ".wgs_bam_qc_runs" / args.prefix / run_id
artifacts: dict[str, dict[str, Any]] = {}
for sample in sorted(completed):
for role, source in completed[sample].items():
artifacts[f"{role}.{sample}"] = _artifact(
workspace,
source,
destination / source.name,
role,
sample,
)
for role, source in (
("bam_qc_summary", summary),
("bam_qc_report", report),
):
artifacts[role] = _artifact(
workspace, source, destination / source.name, role
)
manifest = {
"manifest_version": "1.0",
"stage": STAGE,
"status": "success",
"prefix": args.prefix,
"job_id": args.job_id,
"run_id": run_id,
"start_time": started_at,
"workspace": str(workspace),
"wgs_sample_set_id": sample_set_id,
"wgs_reference_id": reference_id,
"upstream_wgs_analysis_ids": {
"wgs_markdup": markdup_analysis_id,
"wgs_index": index_analysis_id,
},
"wgs_analysis_id": analysis_id,
"parameters": parameters,
"software": {
"samtools": {"path": args.samtools, "version": version}
},
"metrics": {
"input_sample_ids": sorted(expected_samples),
"output_sample_ids": sorted(completed),
"total_samples": len(expected_samples),
"total_reference_bases": sum(lengths.values()),
"coverage_ge_1x": cohort_coverage,
"per_sample": [
sample_metrics[sample] for sample in sorted(sample_metrics)
],
},
"artifacts": artifacts,
}
manifest_work = attempt / "manifest.json"
_atomic_json(manifest_work, manifest)
if not _publish(bundle, destination, manifest_work, authority, started_at):
raise RuntimeError(
"stale WGS BAM QC attempt cannot replace a newer successful run"
)
return 0
except Exception as exc:
_publish_failure(authority, args, run_id, started_at, str(exc))
print(f"{STAGE} failed: {exc}", file=sys.stderr)
return 1
def main(argv: list[str] | None = None) -> int:
return run(build_parser().parse_args(argv))
if __name__ == "__main__":
raise SystemExit(main())