← Files Life Sciences NGS AnalysisARCHIVED FILE
scripts/run_dna_variant_calling.py
41.4 KB · Sep 30, 2026 · 22:50 UTC
#!/usr/bin/env python3
"""Run BAM-to-VCF DNA variant calling with samtools and bcftools."""
from __future__ import annotations
import argparse
import csv
import json
import shlex
import statistics
from pathlib import Path
from typing import Any
import ngs_resource_gate
from ngs_run_utils import (
build_artifact_index,
run_cmd,
run_cmd_stdout_to_file,
slug_timestamp,
software_versions,
tool_preflight,
write_json,
write_standard_manifest,
write_text,
)
from ngs_visualization_utils import (
add_vcf_review_notebook_entry,
artifact_entry,
write_visualization_index,
)
WORKSPACE_ROOT = Path.cwd()
DEFAULT_RUN_ROOT = WORKSPACE_ROOT / "ngs_runs" / "dna_variant_calling"
MQ_INFO_INTEGER_HEADER = (
'##INFO=<ID=MQ,Number=1,Type=Integer,Description="Average mapping quality">'
)
MQ_INFO_FLOAT_HEADER = '##INFO=<ID=MQ,Number=1,Type=Float,Description="Average mapping quality">'
DEFAULT_ANNOTATION_INFO_TAGS = ["AF", "AC", "AN"]
DEFAULT_CALLABLE_MIN_DEPTH = 10
def detect_delimiter(path: Path) -> str:
if path.suffix.lower() in {".tsv", ".tab"}:
return "\t"
return ","
def read_samples(path: Path) -> tuple[list[dict[str, str]], list[str]]:
with path.open(newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle, delimiter=detect_delimiter(path))
rows = [{key: (value or "").strip() for key, value in row.items()} for row in reader]
return rows, list(reader.fieldnames or [])
def load_reference_contigs(reference: Path) -> dict[str, int]:
contigs: dict[str, int] = {}
fai = Path(str(reference) + ".fai")
if not fai.exists():
return contigs
with fai.open("r", encoding="utf-8") as handle:
for line in handle:
fields = line.rstrip().split("\t")
if len(fields) >= 2:
contigs[fields[0]] = int(fields[1])
return contigs
def parse_region(region: str) -> tuple[str, int, int]:
if ":" not in region:
raise ValueError(f"region must use contig:start-end syntax, got: {region}")
contig, coords = region.split(":", 1)
if "-" in coords:
start_s, end_s = coords.split("-", 1)
else:
start_s = coords
end_s = coords
start = int(start_s.replace(",", ""))
end = int(end_s.replace(",", ""))
if start < 1 or end < 1 or end < start:
raise ValueError(f"region has invalid coordinates: {region}")
return contig, start, end
def alt_contig_name(contig: str, available: dict[str, int]) -> str | None:
candidates = [f"chr{contig}", contig.removeprefix("chr")]
for item in candidates:
if item != contig and item in available:
return item
return None
def normalize_region(region: str | None, reference_contigs: dict[str, int]) -> dict[str, Any]:
if not region:
return {"requested": None, "normalized": None, "errors": [], "warnings": []}
errors: list[str] = []
warnings: list[str] = []
try:
contig, start, end = parse_region(region)
except ValueError as exc:
return {"requested": region, "normalized": None, "errors": [str(exc)], "warnings": []}
if contig not in reference_contigs:
suggestion = alt_contig_name(contig, reference_contigs)
if suggestion:
errors.append(
f"region contig '{contig}' was not found in the reference; did you mean '{suggestion}:{start}-{end}'?"
)
else:
errors.append(f"region contig '{contig}' was not found in the reference")
return {"requested": region, "normalized": None, "errors": errors, "warnings": warnings}
contig_length = reference_contigs[contig]
if start > contig_length:
errors.append(f"region start {start} exceeds contig length {contig_length} for {contig}")
if end > contig_length:
warnings.append(
f"region end {end} exceeds contig length {contig_length} for {contig}; clipping to {contig_length}"
)
end = contig_length
normalized = f"{contig}:{start}-{end}" if not errors else None
return {
"requested": region,
"normalized": normalized,
"contig": contig,
"start": start,
"end": end,
"contig_length": contig_length,
"errors": errors,
"warnings": warnings,
}
def validate_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[str, str]]]:
sample_sheet = args.sample_sheet.expanduser().resolve()
reference = args.reference_fasta.expanduser().resolve()
errors: list[str] = []
warnings: list[str] = []
normalized: list[dict[str, str]] = []
columns: list[str] = []
if not sample_sheet.exists():
errors.append(f"sample sheet does not exist: {sample_sheet}")
rows: list[dict[str, str]] = []
else:
try:
rows, columns = read_samples(sample_sheet)
except Exception as exc: # pragma: no cover - defensive parse guard
rows = []
errors.append(f"failed to parse sample sheet {sample_sheet}: {exc}")
if not reference.exists():
errors.append(f"reference FASTA does not exist: {reference}")
reference_contigs = load_reference_contigs(reference)
if not reference_contigs:
warnings.append(
f"reference FASTA index is missing and may be created by samtools faidx: {reference}.fai"
)
region_summary = (
normalize_region(args.region, reference_contigs)
if reference.exists()
else {"requested": args.region, "normalized": None, "errors": [], "warnings": []}
)
errors.extend(region_summary.get("errors", []))
warnings.extend(region_summary.get("warnings", []))
if args.annotation_vcf:
annotation_vcf = args.annotation_vcf.expanduser().resolve()
if not annotation_vcf.exists():
errors.append(f"annotation VCF does not exist: {annotation_vcf}")
if (
not (Path(str(annotation_vcf) + ".tbi")).exists()
and not (Path(str(annotation_vcf) + ".csi")).exists()
):
warnings.append(
f"annotation VCF index is missing and may be required by bcftools annotate: {annotation_vcf}.tbi"
)
sample_names: set[str] = set()
duplicate_names: set[str] = set()
for row_index, row in enumerate(rows, start=2):
sample = row.get("sample") or row.get("sample_id") or f"row_{row_index}"
bam_raw = row.get("bam") or row.get("cram") or ""
if sample in sample_names:
duplicate_names.add(sample)
sample_names.add(sample)
if not bam_raw:
errors.append(f"row {row_index}: bam or cram column is required")
continue
bam = Path(bam_raw).expanduser()
if not bam.is_absolute():
bam = sample_sheet.parent / bam
bam = bam.resolve()
if not bam.exists():
errors.append(f"row {row_index}: alignment file does not exist: {bam}")
if bam.suffix == ".bam" and not (Path(str(bam) + ".bai")).exists():
warnings.append(
f"row {row_index}: BAM index is missing and may be created by samtools index: {bam}.bai"
)
normalized.append({"sample": sample, "alignment": str(bam), "row_index": str(row_index)})
if not normalized:
errors.append("no usable alignment rows found")
if duplicate_names:
warnings.append(
f"duplicate sample names detected in sample sheet: {', '.join(sorted(duplicate_names))}"
)
validation = {
"ok": not errors,
"sample_sheet": str(sample_sheet),
"reference_fasta": str(reference),
"region": region_summary.get("normalized") or args.region,
"region_requested": args.region,
"region_summary": region_summary,
"columns": columns,
"sample_count": len(normalized),
"run_class": "targeted_region_check"
if region_summary.get("normalized")
else "alignment_wide_local_light",
"errors": errors,
"warnings": warnings,
}
return validation, normalized
def write_normalized_samples(run_dir: Path, rows: list[dict[str, str]]) -> None:
path = run_dir / "validation" / "samples.normalized.tsv"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(
handle, fieldnames=["sample", "alignment", "row_index"], delimiter="\t"
)
writer.writeheader()
writer.writerows(rows)
def bcftools_call_command(
reference: Path, alignment: Path, output: Path, region: str | None
) -> str:
mpileup = ["bcftools", "mpileup", "-Ou", "-f", str(reference)]
if region:
mpileup.extend(["-r", region])
mpileup.append(str(alignment))
call = ["bcftools", "call", "-mv", "-Oz", "-o", str(output)]
return f"{shlex.join(mpileup)} | {shlex.join(call)}"
def detect_annotation_columns(run_dir: Path, annotation_vcf: Path, requested: str | None) -> str:
if requested:
return requested
tags_to_probe = "|".join(DEFAULT_ANNOTATION_INFO_TAGS)
probe = run_cmd(
[
"bash",
"-lc",
f"bcftools view -h {shlex.quote(str(annotation_vcf))} | rg '^##INFO=<ID=({tags_to_probe}),'",
],
run_dir,
timeout=600,
)
if not probe.get("ok"):
return "ID"
available_tags = set()
for line in str(probe.get("stdout_tail", "")).splitlines():
if line.startswith("##INFO=<ID="):
tag = line.split("##INFO=<ID=", 1)[1].split(",", 1)[0]
available_tags.add(tag)
selected = ["ID"]
selected.extend(f"INFO/{tag}" for tag in DEFAULT_ANNOTATION_INFO_TAGS if tag in available_tags)
return ",".join(selected)
def annotate_vcf(
run_dir: Path,
sample: str,
input_vcf: Path,
annotation_vcf: Path | None,
annotation_columns: str | None,
) -> tuple[Path, dict[str, Any]]:
if not annotation_vcf:
return input_vcf, {"ok": True, "changed": False, "reason": "annotation not requested"}
columns = detect_annotation_columns(run_dir, annotation_vcf, annotation_columns)
output_vcf = input_vcf.parent / f"{input_vcf.name.removesuffix('.vcf.gz')}.annotated.vcf.gz"
annotate = run_cmd(
[
"bcftools",
"annotate",
"-a",
str(annotation_vcf),
"-c",
columns,
"--pair-logic",
"exact",
"-O",
"z",
"-o",
str(output_vcf),
str(input_vcf),
],
run_dir,
timeout=3600,
)
result: dict[str, Any] = {
"ok": bool(annotate.get("ok")),
"changed": bool(annotate.get("ok")),
"reason": "annotated from resource VCF"
if annotate.get("ok")
else "bcftools annotate failed",
"annotation_vcf": str(annotation_vcf),
"annotation_columns": columns,
"annotate": annotate,
}
return (output_vcf if annotate.get("ok") else input_vcf), result
def normalize_mq_header(run_dir: Path, sample: str, vcf: Path) -> dict[str, Any]:
"""Rewrite INFO/MQ as Float so bcftools stats does not warn on the emitted header."""
header = run_cmd(["bcftools", "view", "-h", str(vcf)], run_dir, timeout=600)
result: dict[str, Any] = {"ok": bool(header.get("ok")), "changed": False}
if not header.get("ok"):
result["reason"] = "failed to read VCF header"
return result
header_lines = [
line
for line in str(header.get("stdout_tail", "")).splitlines()
if line.startswith(("##", "#CHROM"))
]
header_text = "\n".join(header_lines) + ("\n" if header_lines else "")
if MQ_INFO_INTEGER_HEADER not in header_text:
result["reason"] = "no MQ Integer header present"
return result
normalized_header = header_text.replace(MQ_INFO_INTEGER_HEADER, MQ_INFO_FLOAT_HEADER)
header_path = run_dir / "logs" / f"{sample}.mq_header.normalized.hdr"
temp_vcf = vcf.parent / f"{vcf.name.removesuffix('.vcf.gz')}.reheader.vcf.gz"
write_text(header_path, normalized_header)
reheader = run_cmd(
["bcftools", "reheader", "-h", str(header_path), "-o", str(temp_vcf), str(vcf)],
run_dir,
timeout=600,
)
result["reheader"] = reheader
if not reheader.get("ok"):
result["ok"] = False
result["reason"] = "bcftools reheader failed"
return result
temp_vcf.replace(vcf)
result["changed"] = True
result["reason"] = "rewrote INFO/MQ header to Float"
return result
def summarize_depth_file(depth_path: Path, callable_min_depth: int) -> dict[str, Any]:
positions = 0
callable_positions = 0
zero_depth_positions = 0
depth_values: list[int] = []
for line in depth_path.read_text(encoding="utf-8").splitlines():
fields = line.split("\t")
if len(fields) < 3:
continue
depth = int(fields[2])
positions += 1
depth_values.append(depth)
if depth >= callable_min_depth:
callable_positions += 1
if depth == 0:
zero_depth_positions += 1
mean_depth = sum(depth_values) / positions if positions else 0.0
median_depth = statistics.median(depth_values) if depth_values else 0.0
return {
"positions": positions,
"callable_min_depth": callable_min_depth,
"callable_positions": callable_positions,
"callable_fraction": (callable_positions / positions) if positions else 0.0,
"zero_depth_positions": zero_depth_positions,
"mean_depth": round(mean_depth, 3),
"median_depth": round(float(median_depth), 3),
"max_depth": max(depth_values) if depth_values else 0,
}
def parse_variant_stats(stats_text: str) -> dict[str, Any]:
summary: dict[str, Any] = {
"record_count": None,
"snp_count": None,
"indel_count": None,
"interpretation": "variant stats unavailable",
}
for line in stats_text.splitlines():
if not line.startswith("SN\t0\t"):
continue
_, _, key, value = line.split("\t", 3)
value_int = int(value)
if key == "number of records:":
summary["record_count"] = value_int
elif key == "number of SNPs:":
summary["snp_count"] = value_int
elif key == "number of indels:":
summary["indel_count"] = value_int
record_count = summary.get("record_count")
if record_count == 0:
summary["interpretation"] = "no variant records were emitted in the queried region"
elif isinstance(record_count, int) and record_count > 0:
summary["interpretation"] = "variant records were emitted in the queried region"
return summary
def run_region_qc(
run_dir: Path,
sample: str,
alignment: Path,
region: str | None,
callable_min_depth: int,
) -> dict[str, Any]:
qc_dir = run_dir / "qc"
coverage_path = qc_dir / f"{sample}.coverage.tsv"
coverage_cmd = ["samtools", "coverage"]
if region:
coverage_cmd.extend(["-r", region])
coverage_cmd.append(str(alignment))
coverage = run_cmd_stdout_to_file(coverage_cmd, run_dir, coverage_path, timeout=600)
depth_summary: dict[str, Any] = {
"positions": 0,
"callable_min_depth": callable_min_depth,
"callable_positions": 0,
"callable_fraction": 0.0,
"zero_depth_positions": 0,
"mean_depth": 0.0,
"median_depth": 0.0,
"max_depth": 0,
"note": "per-base depth was omitted because no region was provided",
}
depth_log: dict[str, Any] = {"ok": True, "skipped": True, "reason": depth_summary["note"]}
if region:
depth_path = qc_dir / f"{sample}.depth.tsv"
depth_cmd = ["samtools", "depth", "-aa", "-r", region, str(alignment)]
depth_log = run_cmd_stdout_to_file(depth_cmd, run_dir, depth_path, timeout=600)
if depth_log.get("ok"):
depth_summary = summarize_depth_file(depth_path, callable_min_depth)
return {"coverage": coverage, "depth": depth_log, "callability": depth_summary}
def filter_vcf(
run_dir: Path,
input_vcf: Path,
min_qual: float | None,
min_site_dp: int | None,
) -> tuple[Path, dict[str, Any]]:
expressions: list[str] = []
if min_qual is not None:
expressions.append(f"QUAL<{min_qual}")
if min_site_dp is not None:
expressions.append(f"INFO/DP<{min_site_dp}")
if not expressions:
return input_vcf, {"ok": True, "changed": False, "reason": "filtering not requested"}
output_vcf = input_vcf.parent / f"{input_vcf.name.removesuffix('.vcf.gz')}.filtered.vcf.gz"
expr = " || ".join(expressions)
result = run_cmd(
[
"bcftools",
"filter",
"-s",
"LOW_SUPPORT",
"-e",
expr,
"-O",
"z",
"-o",
str(output_vcf),
str(input_vcf),
],
run_dir,
timeout=3600,
)
payload = {
"ok": bool(result.get("ok")),
"changed": bool(result.get("ok")),
"reason": "soft-filtered VCF emitted" if result.get("ok") else "bcftools filter failed",
"expression": expr,
"filter": result,
}
return (output_vcf if result.get("ok") else input_vcf), payload
def write_commands(run_dir: Path, args: argparse.Namespace, rows: list[dict[str, str]]) -> None:
reference = args.reference_fasta.expanduser().resolve()
lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
shlex.join(["samtools", "faidx", str(reference)]),
]
for row in rows:
sample = row["sample"]
alignment = Path(row["alignment"])
lines.append(shlex.join(["samtools", "quickcheck", "-v", str(alignment)]))
lines.append(
shlex.join(["samtools", "flagstat", str(alignment)]) + f" > qc/{sample}.flagstat.txt"
)
lines.append(
shlex.join(["samtools", "idxstats", str(alignment)]) + f" > qc/{sample}.idxstats.tsv"
)
lines.append(
shlex.join(["samtools", "coverage", "-r", args.region, str(alignment)])
+ f" > qc/{sample}.coverage.tsv"
if args.region
else shlex.join(["samtools", "coverage", str(alignment)])
+ f" > qc/{sample}.coverage.tsv"
)
if args.region:
lines.append(
shlex.join(["samtools", "depth", "-aa", "-r", args.region, str(alignment)])
+ f" > qc/{sample}.depth.tsv"
)
lines.append(
bcftools_call_command(
reference, alignment, Path("variants") / f"{sample}.vcf.gz", args.region
)
)
lines.append(
f"# The runner may normalize INFO/MQ in variants/{sample}.vcf.gz before bcftools stats."
)
if args.annotation_vcf:
annotation_vcf = args.annotation_vcf.expanduser().resolve()
columns = detect_annotation_columns(run_dir, annotation_vcf, args.annotation_columns)
lines.append(shlex.join(["bcftools", "index", "-t", f"variants/{sample}.vcf.gz"]))
lines.append(
shlex.join(
[
"bcftools",
"annotate",
"-a",
str(annotation_vcf),
"-c",
columns,
"--pair-logic",
"exact",
"-O",
"z",
"-o",
f"variants/{sample}.annotated.vcf.gz",
f"variants/{sample}.vcf.gz",
]
)
)
lines.append(
shlex.join(["bcftools", "index", "-t", f"variants/{sample}.annotated.vcf.gz"])
)
if args.filter_min_qual is not None or args.filter_min_site_dp is not None:
expr = " || ".join(
[
item
for item in [
f"QUAL<{args.filter_min_qual}"
if args.filter_min_qual is not None
else None,
f"INFO/DP<{args.filter_min_site_dp}"
if args.filter_min_site_dp is not None
else None,
]
if item
]
)
lines.append(
shlex.join(
[
"bcftools",
"filter",
"-s",
"LOW_SUPPORT",
"-e",
expr,
"-O",
"z",
"-o",
f"variants/{sample}.annotated.filtered.vcf.gz",
f"variants/{sample}.annotated.vcf.gz",
]
)
)
lines.append(
shlex.join(
["bcftools", "index", "-t", f"variants/{sample}.annotated.filtered.vcf.gz"]
)
)
lines.append(
shlex.join(
["bcftools", "stats", f"variants/{sample}.annotated.filtered.vcf.gz"]
)
+ f" > variants/{sample}.filtered.bcftools_stats.txt"
)
else:
lines.append(
shlex.join(["bcftools", "stats", f"variants/{sample}.annotated.vcf.gz"])
+ f" > variants/{sample}.bcftools_stats.txt"
)
else:
lines.append(shlex.join(["bcftools", "index", "-t", f"variants/{sample}.vcf.gz"]))
if args.filter_min_qual is not None or args.filter_min_site_dp is not None:
expr = " || ".join(
[
item
for item in [
f"QUAL<{args.filter_min_qual}"
if args.filter_min_qual is not None
else None,
f"INFO/DP<{args.filter_min_site_dp}"
if args.filter_min_site_dp is not None
else None,
]
if item
]
)
lines.append(
shlex.join(
[
"bcftools",
"filter",
"-s",
"LOW_SUPPORT",
"-e",
expr,
"-O",
"z",
"-o",
f"variants/{sample}.filtered.vcf.gz",
f"variants/{sample}.vcf.gz",
]
)
)
lines.append(
shlex.join(["bcftools", "index", "-t", f"variants/{sample}.filtered.vcf.gz"])
)
lines.append(
shlex.join(["bcftools", "stats", f"variants/{sample}.filtered.vcf.gz"])
+ f" > variants/{sample}.filtered.bcftools_stats.txt"
)
else:
lines.append(
shlex.join(["bcftools", "stats", f"variants/{sample}.vcf.gz"])
+ f" > variants/{sample}.bcftools_stats.txt"
)
write_text(run_dir / "commands.sh", "\n".join(lines) + "\n")
def execute(run_dir: Path, args: argparse.Namespace, rows: list[dict[str, str]]) -> dict[str, Any]:
reference = args.reference_fasta.expanduser().resolve()
annotation_vcf = args.annotation_vcf.expanduser().resolve() if args.annotation_vcf else None
results: dict[str, Any] = {"ok": True, "steps": []}
(run_dir / "qc").mkdir(parents=True, exist_ok=True)
(run_dir / "variants").mkdir(parents=True, exist_ok=True)
if not (Path(str(reference) + ".fai")).exists():
faidx = run_cmd(["samtools", "faidx", str(reference)], run_dir, timeout=600)
write_json(run_dir / "logs" / "samtools_faidx.json", faidx)
results["steps"].append({"name": "samtools_faidx", "ok": faidx.get("ok")})
results["ok"] = bool(results["ok"] and faidx.get("ok"))
for row in rows:
sample = row["sample"]
alignment = Path(row["alignment"])
quickcheck = run_cmd(["samtools", "quickcheck", "-v", str(alignment)], run_dir, timeout=300)
write_json(run_dir / "logs" / f"{sample}.quickcheck.json", quickcheck)
flagstat = run_cmd(["samtools", "flagstat", str(alignment)], run_dir, timeout=600)
write_json(run_dir / "logs" / f"{sample}.flagstat.json", flagstat)
write_text(run_dir / "qc" / f"{sample}.flagstat.txt", flagstat.get("stdout_tail", ""))
idxstats = run_cmd(["samtools", "idxstats", str(alignment)], run_dir, timeout=600)
write_json(run_dir / "logs" / f"{sample}.idxstats.json", idxstats)
write_text(run_dir / "qc" / f"{sample}.idxstats.tsv", idxstats.get("stdout_tail", ""))
region_qc = run_region_qc(run_dir, sample, alignment, args.region, args.callable_min_depth)
write_json(run_dir / "logs" / f"{sample}.coverage.json", region_qc["coverage"])
write_json(run_dir / "logs" / f"{sample}.depth.json", region_qc["depth"])
write_json(run_dir / "qc" / f"{sample}.callability.json", region_qc["callability"])
vcf = run_dir / "variants" / f"{sample}.vcf.gz"
call = run_cmd(
["bash", "-c", bcftools_call_command(reference, alignment, vcf, args.region)],
run_dir,
timeout=3600,
)
write_json(run_dir / "logs" / f"{sample}.bcftools_call.json", call)
write_text(run_dir / "logs" / f"{sample}.bcftools_call.log", call.get("stdout_tail", ""))
mq_header_fix = (
normalize_mq_header(run_dir, sample, vcf)
if call.get("ok")
else {"ok": False, "skipped": True}
)
write_json(run_dir / "logs" / f"{sample}.mq_header_fix.json", mq_header_fix)
pre_annotation_index = (
run_cmd(["bcftools", "index", "-t", str(vcf)], run_dir, timeout=600)
if call.get("ok") and mq_header_fix.get("ok")
else {"ok": False, "skipped": True}
)
write_json(run_dir / "logs" / f"{sample}.pre_annotation_index.json", pre_annotation_index)
final_vcf, annotation_result = (
annotate_vcf(run_dir, sample, vcf, annotation_vcf, args.annotation_columns)
if pre_annotation_index.get("ok")
else (vcf, {"ok": False, "skipped": True})
)
write_json(run_dir / "logs" / f"{sample}.annotation.json", annotation_result)
filtered_vcf, filter_result = (
filter_vcf(run_dir, final_vcf, args.filter_min_qual, args.filter_min_site_dp)
if call.get("ok") and mq_header_fix.get("ok") and annotation_result.get("ok")
else (final_vcf, {"ok": False, "skipped": True})
)
write_json(run_dir / "logs" / f"{sample}.filter.json", filter_result)
if call.get("ok") and mq_header_fix.get("ok") and annotation_result.get("ok"):
if filtered_vcf == vcf and pre_annotation_index.get("ok"):
index = {
**pre_annotation_index,
"reused": True,
"reason": "reused pre-annotation index for unannotated VCF",
}
else:
index = run_cmd(
["bcftools", "index", "-t", str(filtered_vcf)], run_dir, timeout=600
)
else:
index = {"ok": False, "skipped": True}
write_json(run_dir / "logs" / f"{sample}.bcftools_index.json", index)
stats = (
run_cmd(["bcftools", "stats", str(filtered_vcf)], run_dir, timeout=600)
if call.get("ok")
and mq_header_fix.get("ok")
and annotation_result.get("ok")
and filter_result.get("ok")
else {"ok": False, "skipped": True}
)
write_json(run_dir / "logs" / f"{sample}.bcftools_stats.json", stats)
write_text(
run_dir / "variants" / f"{sample}.bcftools_stats.txt", stats.get("stdout_tail", "")
)
write_json(
run_dir / "qc" / f"{sample}.variant_summary.json",
parse_variant_stats(str(stats.get("stdout_tail", ""))),
)
sample_ok = bool(
quickcheck.get("ok")
and flagstat.get("ok")
and idxstats.get("ok")
and region_qc["coverage"].get("ok")
and region_qc["depth"].get("ok")
and call.get("ok")
and mq_header_fix.get("ok")
and annotation_result.get("ok")
and filter_result.get("ok")
and index.get("ok")
and stats.get("ok")
)
results["steps"].append({"name": sample, "ok": sample_ok})
results["ok"] = bool(results["ok"] and sample_ok)
return results
def write_summary(
run_dir: Path,
status: str,
validation: dict[str, Any],
annotation_enabled: bool,
filtering_enabled: bool,
resource_plan: dict[str, Any] | None = None,
) -> None:
sample_name = next(iter((run_dir / "qc").glob("*.variant_summary.json")), None)
variant_summary = {}
if sample_name:
variant_summary = json.loads(sample_name.read_text(encoding="utf-8"))
lines = [
"# DNA Variant Calling Run Summary",
"",
f"Status: `{status}`",
f"Samples parsed: `{validation.get('sample_count', 0)}`",
f"Region: `{validation.get('region') or 'whole input alignment'}`",
f"Run class: `{validation.get('run_class')}`",
"",
"## Key Artifacts",
"",
"- `qc/*.flagstat.txt`",
"- `qc/*.idxstats.tsv`",
"- `qc/*.coverage.tsv`",
"- `qc/*.depth.tsv` when `--region` is provided",
"- `qc/*.callability.json` and `qc/*.variant_summary.json`",
"- `variants/*.vcf.gz`",
"- `variants/*.annotated.vcf.gz`" if annotation_enabled else None,
"- `variants/*.filtered.vcf.gz`" if filtering_enabled else None,
"- `variants/*.bcftools_stats.txt`",
"- `visualizations/index.html` and `visualizations/visualization_manifest.json`",
"- `notebooks/vcf_review.marimo.py` when output VCF/gVCF artifacts are present",
"- `resources/resource_plan.json`, `resource_manifest.tsv`, `resource_env.sh`, `resource_readiness.md`, and resource setup-plan artifacts",
"- `run_manifest.json` and `artifact_index.json`",
"",
]
lines = [line for line in lines if line is not None]
if variant_summary:
lines.extend(
[
"## Interpretation",
"",
f"- Record count: `{variant_summary.get('record_count')}`",
f"- SNP count: `{variant_summary.get('snp_count')}`",
f"- Indel count: `{variant_summary.get('indel_count')}`",
f"- Interpretation: {variant_summary.get('interpretation')}",
"",
]
)
lines.extend(
[
"## Guardrails",
"",
"- This local lane is a targeted verification and audit envelope; use subtype lanes for full germline or somatic workflow requirements.",
"- When no annotation VCF or filter thresholds are provided, interpretation is limited to raw bcftools calls in the queried region.",
"- Use the germline/somatic subtype lanes for BQSR, cohort logic, or richer annotation/reporting.",
"",
]
)
if validation.get("warnings"):
lines.extend(["## Warnings", ""])
lines.extend(f"- {warning}" for warning in validation["warnings"])
lines.append("")
lines.extend(ngs_resource_gate.resource_summary_lines(resource_plan))
if validation.get("errors"):
lines.extend(["## Blockers", ""])
lines.extend(f"- {error}" for error in validation["errors"])
write_text(run_dir / "summary.md", "\n".join(lines) + "\n")
def write_visuals(
run_dir: Path,
status: str,
validation: dict[str, Any],
resource_plan: dict[str, Any] | None = None,
) -> dict[str, str]:
first_variant_summary = next(
iter(sorted((run_dir / "qc").glob("*.variant_summary.json"))), None
)
entries = [
artifact_entry(
artifact_id="sample_table",
title="Resolved Sample Table",
path="validation/samples.normalized.tsv",
kind="table",
status="created",
description="Resolved sample table with absolute BAM/CRAM alignment paths.",
),
artifact_entry(
artifact_id="variant_summary",
title="Variant Summary",
path=str(first_variant_summary.relative_to(run_dir)) if first_variant_summary else None,
kind="json",
status="created" if first_variant_summary else "not_available",
description="Per-sample variant counts parsed from bcftools stats.",
),
]
review_outputs = add_vcf_review_notebook_entry(
run_dir,
entries,
title="DNA Variant VCF Review",
table_items=[("Resolved Sample Table", "validation/samples.normalized.tsv")],
object_items=[("Run Summary", "summary.md"), ("Artifact Index", "artifact_index.json")],
)
entries.extend(ngs_resource_gate.resource_visual_entries(resource_plan))
index = write_visualization_index(
run_dir,
title="DNA Variant Review Bundle",
description="Review surface for the local DNA variant lane, including VCF/gVCF notebook previews when variant artifacts are present.",
entries=entries,
notes=[
*validation.get("warnings", []),
*ngs_resource_gate.resource_messages(resource_plan),
],
analysis_intent="real_analysis" if status != "blocked" else "blocked_preflight",
provenance_summary={
"status": status,
"sample_count": validation.get("sample_count", 0),
"resource_plan_ok": validation.get("resource_plan_ok"),
},
)
return {
"visualization_index": str(index.relative_to(run_dir)),
"visualization_manifest": "visualizations/visualization_manifest.json",
**review_outputs,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sample-sheet", type=Path, required=True)
parser.add_argument("--reference-fasta", type=Path, required=True)
parser.add_argument("--region")
parser.add_argument(
"--annotation-vcf",
type=Path,
help="Optional bgzip/tabix-indexed VCF used to annotate called variants.",
)
parser.add_argument(
"--annotation-columns",
help="Optional bcftools annotate -c column list. Defaults to ID plus available AF/AC/AN tags from the resource VCF.",
)
parser.add_argument(
"--filter-min-qual",
type=float,
help="Optional QUAL threshold for soft-filtering emitted variants.",
)
parser.add_argument(
"--filter-min-site-dp",
type=int,
help="Optional INFO/DP threshold for soft-filtering emitted variants.",
)
parser.add_argument(
"--callable-min-depth",
type=int,
default=DEFAULT_CALLABLE_MIN_DEPTH,
help="Minimum depth used to mark a locus callable in region-level depth summaries.",
)
parser.add_argument(
"--genome-build",
help="Genome build or registry alias for resource readiness, e.g. GRCh38, mm39, or a configured local alias.",
)
parser.add_argument(
"--bundle-root",
action="append",
default=[],
help="Resource bundle override formatted as bundle=/path. May be repeated.",
)
parser.add_argument("--include-optional-resources", action="store_true")
parser.add_argument("--resource-checksums", action="store_true")
parser.add_argument(
"--require-resource-plan",
action="store_true",
help="Treat missing registered reference bundles as blocking for this local runner.",
)
parser.add_argument(
"--skip-resource-plan",
action="store_true",
help="Skip registered reference bundle readiness checks.",
)
parser.add_argument("--outdir", type=Path)
parser.add_argument("--run-id", default=slug_timestamp("dna-variant-calling"))
parser.add_argument("--execute", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
run_dir = (args.outdir or (DEFAULT_RUN_ROOT / args.run_id)).expanduser().resolve()
if run_dir.exists():
raise FileExistsError(f"run directory already exists: {run_dir}")
run_dir.mkdir(parents=True)
(run_dir / "logs").mkdir(parents=True, exist_ok=True)
input_validation, rows = validate_inputs(args)
resource_plan = ngs_resource_gate.write_pipeline_resource_plan(
run_dir=run_dir,
pipeline="dna_variant_calling",
genome_build=args.genome_build,
bundle_roots=args.bundle_root,
include_optional=args.include_optional_resources,
include_checksums=args.resource_checksums,
skip=args.skip_resource_plan,
required=args.require_resource_plan,
)
validation = ngs_resource_gate.merge_resource_status(
input_validation,
resource_plan,
required=args.require_resource_plan,
)
tool_status = tool_preflight(["samtools", "bcftools"], optional=[])
write_json(
run_dir / "config.json",
{
"reference_fasta": str(args.reference_fasta.expanduser().resolve()),
"region": validation.get("region"),
"region_requested": args.region,
"filter_min_qual": args.filter_min_qual,
"filter_min_site_dp": args.filter_min_site_dp,
"callable_min_depth": args.callable_min_depth,
"run_class": validation.get("run_class"),
},
)
write_json(run_dir / "validation" / "input_summary.json", {"samples": rows})
write_json(run_dir / "validation" / "input_validation_summary.json", input_validation)
write_json(run_dir / "validation" / "validation_summary.json", validation)
write_json(run_dir / "validation" / "tool_preflight.json", tool_status)
write_normalized_samples(run_dir, rows)
write_commands(run_dir, args, rows)
write_json(
run_dir / "versions" / "software_versions.json",
software_versions(
{"samtools": ["samtools", "--version"], "bcftools": ["bcftools", "--version"]}
),
)
dry_run = {
"ok": validation["ok"] and tool_status["ok"],
"detail": "input and tool validation completed",
}
write_json(run_dir / "logs" / "validation_dry_run.json", dry_run)
execution = None
status = "blocked" if not dry_run["ok"] else "validated"
if args.execute and dry_run["ok"]:
execution = execute(run_dir, args, rows)
status = "completed" if execution.get("ok") else "failed"
visuals = write_visuals(run_dir, status, validation, resource_plan)
resource_outputs = ngs_resource_gate.resource_output_paths(resource_plan)
write_standard_manifest(
run_dir,
run_id=args.run_id,
lane="dna_variant_calling",
workflow="local_light_samtools_bcftools",
status=status,
execute_requested=args.execute,
validation=validation,
tool_preflight_result=tool_status,
dry_run=dry_run,
execution=execution,
inputs={
"sample_sheet": str(args.sample_sheet.expanduser().resolve()),
"reference_fasta": str(args.reference_fasta.expanduser().resolve()),
"annotation_vcf": str(args.annotation_vcf.expanduser().resolve())
if args.annotation_vcf
else None,
**(
{"resource_plan": resource_outputs.get("resource_plan")} if resource_outputs else {}
),
},
outputs={
"vcf_glob": "variants/*.vcf.gz",
"annotated_vcf_glob": "variants/*.annotated.vcf.gz" if args.annotation_vcf else None,
"filtered_vcf_glob": "variants/*.filtered.vcf.gz"
if args.filter_min_qual is not None or args.filter_min_site_dp is not None
else None,
"flagstat_glob": "qc/*.flagstat.txt",
"idxstats_glob": "qc/*.idxstats.tsv",
"coverage_glob": "qc/*.coverage.tsv",
"depth_glob": "qc/*.depth.tsv" if validation.get("region") else None,
"callability_glob": "qc/*.callability.json",
**resource_outputs,
**visuals,
},
method={
"caller": "bcftools mpileup/call",
"region": validation.get("region"),
"annotation_columns": args.annotation_columns,
"filter_min_qual": args.filter_min_qual,
"filter_min_site_dp": args.filter_min_site_dp,
"callable_min_depth": args.callable_min_depth,
"run_class": validation.get("run_class"),
"resource_plan": resource_plan,
},
audit={"resource_readiness": resource_plan} if resource_plan else None,
review_bundle=visuals,
)
write_summary(
run_dir,
status,
validation,
bool(args.annotation_vcf),
bool(args.filter_min_qual is not None or args.filter_min_site_dp is not None),
resource_plan,
)
write_json(run_dir / "artifact_index.json", build_artifact_index(run_dir))
print(run_dir)
return 1 if status in {"blocked", "failed"} else 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: b6d341c9013f9e05bf0184bec69f8a3210e90cb2c8b704f81a07825fedc54a26