← Files Life Sciences NGS AnalysisARCHIVED FILE

scripts/run_dna_germline_variants.py

24.7 KB · Sep 30, 2026 · 22:50 UTC

↓ Download file

#!/usr/bin/env python3
"""Run germline DNA variant calling with optional BQSR, gVCF, and joint genotyping."""

from __future__ import annotations

import argparse
import csv
import shlex
from pathlib import Path
from typing import Any

import ngs_resource_gate
from ngs_run_utils import (
    build_artifact_index,
    run_cmd,
    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_germline_variants"


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 bqsr_enabled(args: argparse.Namespace) -> bool:
    if args.bqsr_mode == "off":
        return False
    if args.bqsr_mode == "force":
        return True
    return bool(args.known_sites)


def use_gvcf(args: argparse.Namespace, sample_count: int) -> bool:
    return bool(args.emit_gvcf or args.joint_call or sample_count > 1)


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()
    target_bed = args.target_bed.expanduser().resolve() if args.target_bed else None
    rows, columns = read_samples(sample_sheet)
    errors: list[str] = []
    warnings: list[str] = []
    normalized: list[dict[str, str]] = []

    if not reference.exists():
        errors.append(f"reference FASTA does not exist: {reference}")
    if not (Path(str(reference) + ".fai")).exists():
        warnings.append(
            f"reference FASTA index is missing and may be created by samtools faidx: {reference}.fai"
        )
    if not (reference.with_suffix(".dict")).exists():
        warnings.append(
            f"reference sequence dictionary is missing and may be created by GATK: {reference.with_suffix('.dict')}"
        )
    if target_bed and not target_bed.exists():
        errors.append(f"target BED does not exist: {target_bed}")
    if args.bqsr_mode == "force" and not args.known_sites:
        errors.append("BQSR was forced but no --known-sites VCFs were provided")

    known_sites: list[str] = []
    for item in args.known_sites:
        resource = item.expanduser().resolve()
        known_sites.append(str(resource))
        if not resource.exists():
            errors.append(f"known-sites VCF does not exist: {resource}")
        if (
            not (Path(str(resource) + ".tbi")).exists()
            and not (Path(str(resource) + ".csi")).exists()
        ):
            warnings.append(
                f"known-sites VCF index is missing and may be required by GATK: {resource}.tbi"
            )

    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 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 args.joint_call and len(normalized) < 2:
        warnings.append(
            "joint calling was requested with fewer than two samples; GenotypeGVCFs can still run, but a cohort VCF may not add value"
        )

    validation = {
        "ok": not errors,
        "sample_sheet": str(sample_sheet),
        "reference_fasta": str(reference),
        "target_bed": str(target_bed) if target_bed else None,
        "known_sites": known_sites,
        "columns": columns,
        "sample_count": len(normalized),
        "sample_model": args.sample_model,
        "bqsr_enabled": bqsr_enabled(args),
        "emit_gvcf": use_gvcf(args, len(normalized)),
        "joint_call": args.joint_call,
        "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 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)]),
    ]
    if bqsr_enabled(args):
        for row in rows:
            sample = row["sample"]
            known_sites_args = []
            for resource in args.known_sites:
                known_sites_args.extend(["--known-sites", str(resource.expanduser().resolve())])
            lines.append(
                shlex.join(
                    [
                        "gatk",
                        "BaseRecalibrator",
                        "-R",
                        str(reference),
                        "-I",
                        row["alignment"],
                        *known_sites_args,
                        "-O",
                        f"recal/{sample}.recal.table",
                    ]
                )
            )
            lines.append(
                shlex.join(
                    [
                        "gatk",
                        "ApplyBQSR",
                        "-R",
                        str(reference),
                        "-I",
                        row["alignment"],
                        "--bqsr-recal-file",
                        f"recal/{sample}.recal.table",
                        "-O",
                        f"recal/{sample}.recal.bam",
                    ]
                )
            )
    for row in rows:
        sample = row["sample"]
        input_bam = (
            Path(f"recal/{sample}.recal.bam") if bqsr_enabled(args) else Path(row["alignment"])
        )
        lines.append(
            shlex.join(["samtools", "flagstat", str(input_bam)]) + f" > qc/{sample}.flagstat.txt"
        )
        lines.append(
            shlex.join(["samtools", "idxstats", str(input_bam)]) + f" > qc/{sample}.idxstats.tsv"
        )
        hc_cmd = ["gatk", "HaplotypeCaller", "-R", str(reference), "-I", str(input_bam)]
        if args.target_bed:
            hc_cmd.extend(["-L", str(args.target_bed.expanduser().resolve())])
        if use_gvcf(args, len(rows)):
            hc_cmd.extend(["-ERC", "GVCF", "-O", f"gvcf/{sample}.g.vcf.gz"])
        else:
            hc_cmd.extend(["-O", f"variants/{sample}.vcf.gz"])
        lines.append(shlex.join(hc_cmd))
    if args.joint_call:
        combine_cmd = ["gatk", "CombineGVCFs", "-R", str(reference)]
        for row in rows:
            combine_cmd.extend(["-V", f"gvcf/{row['sample']}.g.vcf.gz"])
        combine_cmd.extend(["-O", "joint/cohort.combined.g.vcf.gz"])
        lines.append(shlex.join(combine_cmd))
        lines.append(
            shlex.join(
                [
                    "gatk",
                    "GenotypeGVCFs",
                    "-R",
                    str(reference),
                    "-V",
                    "joint/cohort.combined.g.vcf.gz",
                    "-O",
                    "joint/cohort.joint.vcf.gz",
                ]
            )
        )
    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()
    results: dict[str, Any] = {"ok": True, "steps": []}
    (run_dir / "qc").mkdir(parents=True, exist_ok=True)
    (run_dir / "recal").mkdir(parents=True, exist_ok=True)
    (run_dir / "gvcf").mkdir(parents=True, exist_ok=True)
    (run_dir / "variants").mkdir(parents=True, exist_ok=True)
    (run_dir / "joint").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"))

    reference_dict = reference.with_suffix(".dict")
    if not reference_dict.exists():
        create_dict = run_cmd(
            ["gatk", "CreateSequenceDictionary", "-R", str(reference), "-O", str(reference_dict)],
            run_dir,
            timeout=600,
        )
        write_json(run_dir / "logs" / "gatk_create_dict.json", create_dict)
        results["steps"].append({"name": "gatk_create_dict", "ok": create_dict.get("ok")})
        results["ok"] = bool(results["ok"] and create_dict.get("ok"))

    gvcfs: list[Path] = []
    for row in rows:
        sample = row["sample"]
        input_bam = Path(row["alignment"])
        if bqsr_enabled(args):
            known_sites_args = []
            for resource in args.known_sites:
                known_sites_args.extend(["--known-sites", str(resource.expanduser().resolve())])
            recal_table = run_dir / "recal" / f"{sample}.recal.table"
            recal = run_cmd(
                [
                    "gatk",
                    "BaseRecalibrator",
                    "-R",
                    str(reference),
                    "-I",
                    str(input_bam),
                    *known_sites_args,
                    "-O",
                    str(recal_table),
                ],
                run_dir,
                timeout=3600,
            )
            write_json(run_dir / "logs" / f"{sample}.base_recalibrator.json", recal)
            apply_bqsr = run_cmd(
                [
                    "gatk",
                    "ApplyBQSR",
                    "-R",
                    str(reference),
                    "-I",
                    str(input_bam),
                    "--bqsr-recal-file",
                    str(recal_table),
                    "-O",
                    str(run_dir / "recal" / f"{sample}.recal.bam"),
                ],
                run_dir,
                timeout=3600,
            )
            write_json(run_dir / "logs" / f"{sample}.apply_bqsr.json", apply_bqsr)
            input_bam = run_dir / "recal" / f"{sample}.recal.bam"
            bam_index = run_cmd(["samtools", "index", str(input_bam)], run_dir, timeout=600)
            write_json(run_dir / "logs" / f"{sample}.recal_index.json", bam_index)
        quickcheck = run_cmd(["samtools", "quickcheck", "-v", str(input_bam)], run_dir, timeout=300)
        write_json(run_dir / "logs" / f"{sample}.quickcheck.json", quickcheck)
        flagstat = run_cmd(["samtools", "flagstat", str(input_bam)], 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(input_bam)], 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", ""))

        output_vcf = (
            run_dir
            / ("gvcf" if use_gvcf(args, len(rows)) else "variants")
            / (f"{sample}.g.vcf.gz" if use_gvcf(args, len(rows)) else f"{sample}.vcf.gz")
        )
        haplotype_caller_cmd = [
            "gatk",
            "HaplotypeCaller",
            "-R",
            str(reference),
            "-I",
            str(input_bam),
        ]
        if args.target_bed:
            haplotype_caller_cmd.extend(["-L", str(args.target_bed.expanduser().resolve())])
        if use_gvcf(args, len(rows)):
            haplotype_caller_cmd.extend(["-ERC", "GVCF"])
        haplotype_caller_cmd.extend(["-O", str(output_vcf)])
        hc = run_cmd(haplotype_caller_cmd, run_dir, timeout=7200)
        write_json(run_dir / "logs" / f"{sample}.haplotypecaller.json", hc)
        sample_ok = bool(
            quickcheck.get("ok") and flagstat.get("ok") and idxstats.get("ok") and hc.get("ok")
        )
        results["steps"].append({"name": sample, "ok": sample_ok})
        results["ok"] = bool(results["ok"] and sample_ok)
        if use_gvcf(args, len(rows)):
            gvcfs.append(output_vcf)

    if args.joint_call and gvcfs:
        combine_cmd = ["gatk", "CombineGVCFs", "-R", str(reference)]
        for item in gvcfs:
            combine_cmd.extend(["-V", str(item)])
        combined_gvcf = run_dir / "joint" / "cohort.combined.g.vcf.gz"
        combine_cmd.extend(["-O", str(combined_gvcf)])
        combine = run_cmd(combine_cmd, run_dir, timeout=7200)
        write_json(run_dir / "logs" / "cohort.combine_gvcfs.json", combine)
        genotype = run_cmd(
            [
                "gatk",
                "GenotypeGVCFs",
                "-R",
                str(reference),
                "-V",
                str(combined_gvcf),
                "-O",
                str(run_dir / "joint" / "cohort.joint.vcf.gz"),
            ],
            run_dir,
            timeout=7200,
        )
        write_json(run_dir / "logs" / "cohort.genotype_gvcfs.json", genotype)
        joint_ok = bool(combine.get("ok") and genotype.get("ok"))
        results["steps"].append({"name": "joint_call", "ok": joint_ok})
        results["ok"] = bool(results["ok"] and joint_ok)
    return results


def write_summary(
    run_dir: Path,
    status: str,
    validation: dict[str, Any],
    resource_plan: dict[str, Any] | None = None,
) -> None:
    lines = [
        "# Germline DNA Variant Calling Run Summary",
        "",
        f"Status: `{status}`",
        f"Sample model: `{validation.get('sample_model')}`",
        f"BQSR enabled: `{validation.get('bqsr_enabled')}`",
        f"Emit gVCF: `{validation.get('emit_gvcf')}`",
        f"Joint call: `{validation.get('joint_call')}`",
        "",
        "## Key Artifacts",
        "",
        "- `qc/*.flagstat.txt`",
        "- `qc/*.idxstats.tsv`",
        "- `recal/*.recal.table` and `recal/*.recal.bam` when BQSR runs",
        "- `gvcf/*.g.vcf.gz` for per-sample GVCFs",
        "- `joint/cohort.joint.vcf.gz` for joint genotyping",
        "- `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`",
        "",
    ]
    if validation.get("warnings"):
        lines.extend(["## Warnings", ""])
        lines.extend(f"- {item}" for item 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"- {item}" for item in validation["errors"])
        lines.append("")
    write_text(run_dir / "summary.md", "\n".join(lines))


def write_visuals(
    run_dir: Path,
    status: str,
    validation: dict[str, Any],
    resource_plan: dict[str, Any] | None = None,
) -> dict[str, str]:
    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 alignment paths used by the germline runner.",
        ),
    ]
    review_outputs = add_vcf_review_notebook_entry(
        run_dir,
        entries,
        title="Germline DNA 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="Germline DNA Review Bundle",
        description="Review surface for the GATK germline lane, including generic 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(
        "--known-sites",
        type=Path,
        action="append",
        default=[],
        help="Repeat for each known-sites VCF used by BaseRecalibrator.",
    )
    parser.add_argument("--target-bed", type=Path)
    parser.add_argument(
        "--sample-model",
        choices=["singleton", "cohort", "duo", "trio", "family"],
        default="singleton",
    )
    parser.add_argument("--bqsr-mode", choices=["auto", "off", "force"], default="auto")
    parser.add_argument(
        "--emit-gvcf",
        action="store_true",
        help="Emit per-sample gVCFs even without joint genotyping.",
    )
    parser.add_argument(
        "--joint-call", action="store_true", help="Combine per-sample gVCFs and run GenotypeGVCFs."
    )
    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-germline-variants"))
    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_germline_variants",
        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,
    )
    required_tools = ["samtools", "gatk"] if args.execute else []
    optional_tools = [
        name
        for name in ["samtools", "gatk", "bcftools", "deepvariant"]
        if name not in required_tools
    ]
    tool_status = tool_preflight(required_tools, optional=optional_tools)
    write_json(
        run_dir / "config.json",
        {
            "reference_fasta": str(args.reference_fasta.expanduser().resolve()),
            "known_sites": [str(item.expanduser().resolve()) for item in args.known_sites],
            "target_bed": str(args.target_bed.expanduser().resolve()) if args.target_bed else None,
            "sample_model": args.sample_model,
            "bqsr_mode": args.bqsr_mode,
            "emit_gvcf": args.emit_gvcf,
            "joint_call": args.joint_call,
        },
    )
    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"],
                "gatk": ["gatk", "--version"],
                "bcftools": ["bcftools", "--version"],
            }
        ),
    )

    dry_run_ok = validation["ok"] and (tool_status["ok"] if args.execute else True)
    dry_run = {"ok": dry_run_ok, "detail": "input, resource, 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_germline_variants",
        workflow="gatk_bqsr_haplotypecaller_joint_genotyping",
        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()),
            "known_sites": [str(item.expanduser().resolve()) for item in args.known_sites],
            "target_bed": str(args.target_bed.expanduser().resolve()) if args.target_bed else None,
            **(
                {"resource_plan": resource_outputs.get("resource_plan")} if resource_outputs else {}
            ),
        },
        outputs={
            "flagstat_glob": "qc/*.flagstat.txt",
            "idxstats_glob": "qc/*.idxstats.tsv",
            "recal_table_glob": "recal/*.recal.table",
            "recal_bam_glob": "recal/*.recal.bam",
            "gvcf_glob": "gvcf/*.g.vcf.gz",
            "joint_vcf_glob": "joint/cohort.joint.vcf.gz" if args.joint_call else None,
            **resource_outputs,
            **visuals,
        },
        method={
            "sample_model": args.sample_model,
            "bqsr_mode": args.bqsr_mode,
            "emit_gvcf": use_gvcf(args, len(rows)),
            "joint_call": args.joint_call,
            "resource_plan": resource_plan,
        },
        audit={"resource_readiness": resource_plan} if resource_plan else None,
        review_bundle=visuals,
    )
    write_summary(run_dir, status, validation, 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: f32fe49fbb81e2e1af29184db9fa41378f2fa3b2231627e5147e6cac697a4da9