← Files Life Sciences NGS AnalysisARCHIVED FILE

scripts/run_atacseq_peaks_qc.py

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

↓ Download file

#!/usr/bin/env python3
"""Run or plan local ATAC-seq alignment, QC, peak, signal, and FRiP artifacts."""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import Any

import ngs_resource_gate
from ngs_epigenomics_utils import summarize_epigenomics_outputs
from ngs_planner_utils import (
    command_plan_entry,
    normalize_sample_name,
    read_table,
    resolve_path,
    shell_join,
    write_command_script,
    write_tsv,
)
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 artifact_entry, write_visualization_index

WORKSPACE_ROOT = Path.cwd()
DEFAULT_RUN_ROOT = WORKSPACE_ROOT / "ngs_runs" / "atacseq_peaks_qc"


def validate_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[str, str]]]:
    sample_sheet = args.sample_sheet.expanduser().resolve()
    errors: list[str] = []
    warnings: list[str] = []
    rows: list[dict[str, str]] = []
    columns: list[str] = []
    samples: list[dict[str, str]] = []
    if not sample_sheet.exists():
        errors.append(f"sample sheet does not exist: {sample_sheet}")
    else:
        rows, columns = read_table(sample_sheet)
    if not args.bam_only and not args.bowtie2_index:
        warnings.append(
            "no --bowtie2-index was provided; FASTQ rows can only be planned, not aligned"
        )
    if not args.genome_size:
        errors.append(
            "--genome-size is required for MACS2 peak calling, e.g. hs, mm, or an effective genome size"
        )
    blacklist = args.blacklist_bed.expanduser().resolve() if args.blacklist_bed else None
    if blacklist and not blacklist.exists():
        errors.append(f"blacklist BED does not exist: {blacklist}")
    tss_bed = args.tss_bed.expanduser().resolve() if args.tss_bed else None
    if tss_bed and not tss_bed.exists():
        warnings.append(
            f"TSS BED does not exist; TSS enrichment commands will be skipped: {tss_bed}"
        )
    if getattr(args, "run_motifs", False) and not getattr(args, "motif_genome", None):
        errors.append(
            "--run-motifs requires --motif-genome, for example hg38, mm10, or a HOMER genome identifier"
        )

    for row_index, row in enumerate(rows, start=2):
        sample = normalize_sample_name(
            row.get("sample") or row.get("sample_id"), f"row_{row_index}"
        )
        bam = resolve_path(row.get("bam") or row.get("alignment"), sample_sheet.parent)
        r1 = resolve_path(row.get("r1") or row.get("fastq_1"), sample_sheet.parent)
        r2 = resolve_path(row.get("r2") or row.get("fastq_2"), sample_sheet.parent)
        if bam:
            if not bam.exists():
                errors.append(f"row {row_index}: BAM does not exist: {bam}")
            layout = "bam"
        elif r1:
            if not r1.exists():
                errors.append(f"row {row_index}: R1 FASTQ does not exist: {r1}")
            if r2 and not r2.exists():
                errors.append(f"row {row_index}: R2 FASTQ does not exist: {r2}")
            layout = "fastq_pe" if r2 else "fastq_se"
        else:
            errors.append(f"row {row_index}: provide bam/alignment or r1/fastq_1")
            continue
        samples.append(
            {
                "sample": sample,
                "condition": row.get("condition", ""),
                "replicate": row.get("replicate", ""),
                "layout": layout,
                "bam": str(bam) if bam else "",
                "r1": str(r1) if r1 else "",
                "r2": str(r2) if r2 else "",
                "row_index": str(row_index),
            }
        )
    if not samples:
        errors.append("no usable ATAC-seq samples found")
    validation = {
        "ok": not errors,
        "sample_sheet": str(sample_sheet),
        "columns": columns,
        "sample_count": len(samples),
        "blacklist_bed": str(blacklist) if blacklist else None,
        "tss_bed": str(tss_bed) if tss_bed else None,
        "genome_size": args.genome_size,
        "run_motifs": getattr(args, "run_motifs", False),
        "motif_genome": getattr(args, "motif_genome", None),
        "motif_size": getattr(args, "motif_size", None),
        "errors": errors,
        "warnings": warnings,
    }
    return validation, samples


def sample_bam_path(sample: dict[str, str]) -> str:
    return (
        sample["bam"] if sample["layout"] == "bam" else f"alignment/{sample['sample']}.sorted.bam"
    )


def build_plan(args: argparse.Namespace, samples: list[dict[str, str]]) -> list[dict[str, Any]]:
    plan: list[dict[str, Any]] = []
    for sample in samples:
        name = sample["sample"]
        bam = sample_bam_path(sample)
        filtered_bam = f"alignment/{name}.filtered.bam"
        if sample["layout"].startswith("fastq"):
            bowtie = [
                "bowtie2",
                "-x",
                args.bowtie2_index or "MISSING_BOWTIE2_INDEX",
                "-p",
                str(args.threads),
            ]
            if sample["r2"]:
                bowtie.extend(["-1", sample["r1"], "-2", sample["r2"]])
            else:
                bowtie.extend(["-U", sample["r1"]])
            plan.append(
                command_plan_entry(
                    f"{name}: align and sort",
                    f"{shell_join(bowtie)} | {shell_join(['samtools', 'sort', '-@', str(args.threads), '-o', bam, '-'])}",
                    outputs=[bam],
                )
            )
            plan.append(
                command_plan_entry(f"{name}: index aligned BAM", ["samtools", "index", bam])
            )
        plan.append(
            command_plan_entry(
                f"{name}: filter alignment",
                [
                    "samtools",
                    "view",
                    "-b",
                    "-q",
                    str(args.min_mapq),
                    "-F",
                    "1804",
                    "-o",
                    filtered_bam,
                    bam,
                ],
                outputs=[filtered_bam],
            )
        )
        plan.append(
            command_plan_entry(f"{name}: index filtered BAM", ["samtools", "index", filtered_bam])
        )
        plan.append(
            command_plan_entry(
                f"{name}: flagstat",
                f"{shell_join(['samtools', 'flagstat', filtered_bam])} > {shell_join([f'qc/{name}.flagstat.txt'])}",
                outputs=[f"qc/{name}.flagstat.txt"],
            )
        )
        plan.append(
            command_plan_entry(
                f"{name}: insert sizes",
                f"{shell_join(['samtools', 'view', '-f', '2', filtered_bam])} | awk '{{t=$9; if (t<0) t=-t; if (t>0) print t}}' > {shell_join([f'qc/{name}.insert_sizes.txt'])}",
                outputs=[f"qc/{name}.insert_sizes.txt"],
            )
        )
        peak_cmd: list[str | Path] = [
            "macs2",
            "callpeak",
            "-t",
            filtered_bam,
            "-f",
            "BAMPE",
            "-g",
            args.genome_size,
            "-n",
            name,
            "--outdir",
            "peaks",
            "--keep-dup",
            "all",
        ]
        plan.append(
            command_plan_entry(
                f"{name}: MACS2 peaks", peak_cmd, outputs=[f"peaks/{name}_peaks.narrowPeak"]
            )
        )
        if args.blacklist_bed:
            plan.append(
                command_plan_entry(
                    f"{name}: blacklist-filter peaks",
                    f"{shell_join(['bedtools', 'intersect', '-v', '-a', f'peaks/{name}_peaks.narrowPeak', '-b', args.blacklist_bed.expanduser().resolve()])} > {shell_join([f'peaks/{name}.blacklist_filtered.narrowPeak'])}",
                    outputs=[f"peaks/{name}.blacklist_filtered.narrowPeak"],
                )
            )
        plan.append(
            command_plan_entry(
                f"{name}: FRiP numerator",
                f"{shell_join(['bedtools', 'intersect', '-u', '-abam', filtered_bam, '-b', f'peaks/{name}_peaks.narrowPeak'])} | {shell_join(['samtools', 'view', '-c', '-'])} > {shell_join([f'qc/{name}.frip_reads.txt'])}",
                outputs=[f"qc/{name}.frip_reads.txt"],
            )
        )
        plan.append(
            command_plan_entry(
                f"{name}: total filtered reads",
                f"{shell_join(['samtools', 'view', '-c', filtered_bam])} > {shell_join([f'qc/{name}.filtered_reads.txt'])}",
                outputs=[f"qc/{name}.filtered_reads.txt"],
            )
        )
        plan.append(
            command_plan_entry(
                f"{name}: bigWig signal",
                [
                    "bamCoverage",
                    "-b",
                    filtered_bam,
                    "-o",
                    f"tracks/{name}.bw",
                    "--numberOfProcessors",
                    str(args.threads),
                ],
                outputs=[f"tracks/{name}.bw"],
            )
        )
        if args.tss_bed:
            plan.append(
                command_plan_entry(
                    f"{name}: TSS enrichment matrix",
                    [
                        "computeMatrix",
                        "reference-point",
                        "-S",
                        f"tracks/{name}.bw",
                        "-R",
                        args.tss_bed.expanduser().resolve(),
                        "--referencePoint",
                        "TSS",
                        "-b",
                        "2000",
                        "-a",
                        "2000",
                        "-o",
                        f"qc/{name}.tss_matrix.gz",
                    ],
                    outputs=[f"qc/{name}.tss_matrix.gz"],
                )
            )
            plan.append(
                command_plan_entry(
                    f"{name}: TSS enrichment profile",
                    [
                        "plotProfile",
                        "-m",
                        f"qc/{name}.tss_matrix.gz",
                        "-out",
                        f"qc/{name}.tss_profile.png",
                        "--plotTitle",
                        f"{name} TSS enrichment",
                    ],
                    outputs=[f"qc/{name}.tss_profile.png"],
                )
            )
            plan.append(
                command_plan_entry(
                    f"{name}: TSS enrichment heatmap",
                    [
                        "plotHeatmap",
                        "-m",
                        f"qc/{name}.tss_matrix.gz",
                        "-out",
                        f"qc/{name}.tss_heatmap.png",
                        "--plotTitle",
                        f"{name} TSS enrichment",
                    ],
                    outputs=[f"qc/{name}.tss_heatmap.png"],
                )
            )
        if getattr(args, "run_motifs", False):
            motif_genome = getattr(args, "motif_genome", None) or "MISSING_MOTIF_GENOME"
            motif_size = str(getattr(args, "motif_size", "given"))
            motif_peak = (
                f"peaks/{name}.blacklist_filtered.narrowPeak"
                if args.blacklist_bed
                else f"peaks/{name}_peaks.narrowPeak"
            )
            plan.append(
                command_plan_entry(
                    f"{name}: motif enrichment",
                    [
                        "findMotifsGenome.pl",
                        motif_peak,
                        motif_genome,
                        f"motifs/{name}",
                        "-size",
                        motif_size,
                    ],
                    outputs=[f"motifs/{name}/knownResults.txt", f"motifs/{name}/homerResults.html"],
                )
            )
    plan.append(
        command_plan_entry(
            "consensus peak merge",
            f"cat peaks/*_peaks.narrowPeak 2>/dev/null | sort -k1,1 -k2,2n | {shell_join(['bedtools', 'merge', '-i', '-'])} > peaks/consensus_peaks.bed",
            outputs=["peaks/consensus_peaks.bed"],
        )
    )
    return plan


def write_outputs(
    run_dir: Path,
    validation: dict[str, Any],
    samples: list[dict[str, str]],
    plan: list[dict[str, Any]],
) -> None:
    write_tsv(
        run_dir / "validation" / "samples.normalized.tsv",
        samples,
        ["sample", "condition", "replicate", "layout", "bam", "r1", "r2", "row_index"],
    )
    write_json(run_dir / "workflow" / "atacseq_command_plan.json", {"commands": plan})
    write_command_script(run_dir / "commands.sh", [item["command"] for item in plan])
    write_json(
        run_dir / "qc" / "atac_qc_contract.json",
        {
            "required_review_metrics": [
                "alignment_rate",
                "duplicate_rate",
                "mitochondrial_fraction",
                "insert_size_periodicity",
                "TSS_enrichment",
                "FRiP",
                "blacklist_overlap",
                "replicate_concordance",
            ],
            "available_after_execution": [
                "qc/*.flagstat.txt",
                "qc/*.insert_sizes.txt",
                "qc/*.frip_reads.txt",
                "qc/*.filtered_reads.txt",
                "peaks/*.narrowPeak",
                "tracks/*.bw",
            ],
            "warnings": validation.get("warnings", []),
        },
    )
    summarize_epigenomics_outputs(
        run_dir, samples, peak_mode="narrow", output_prefix="atacseq_qc", title="ATAC-seq"
    )


def execute_plan(run_dir: Path, plan: list[dict[str, Any]]) -> dict[str, Any]:
    for dirname in ["alignment", "qc", "peaks", "tracks", "logs", "motifs"]:
        (run_dir / dirname).mkdir(parents=True, exist_ok=True)
    result: dict[str, Any] = {"ok": True, "steps": []}
    for index, item in enumerate(plan, start=1):
        step = run_cmd(["bash", "-c", item["command"]], run_dir, timeout=7200)
        safe = item["name"].replace(":", "").replace(" ", "_").replace("/", "_")
        write_json(run_dir / "logs" / f"{index:02d}_{safe}.json", step)
        result["steps"].append({"name": item["name"], "ok": step.get("ok")})
        result["ok"] = bool(result["ok"] and step.get("ok"))
        if not step.get("ok"):
            break
    return result


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="samples",
            title="ATAC Samples",
            path="validation/samples.normalized.tsv",
            kind="table",
            status="created",
            description="Normalized ATAC sample table.",
        ),
        artifact_entry(
            artifact_id="command_plan",
            title="ATAC Command Plan",
            path="workflow/atacseq_command_plan.json",
            kind="json",
            status="created",
            description="Alignment, filtering, peak, FRiP, signal, and TSS command plan.",
        ),
        artifact_entry(
            artifact_id="qc_contract",
            title="ATAC QC Contract",
            path="qc/atac_qc_contract.json",
            kind="json",
            status="created",
            description="Metrics required for interpretation and artifacts expected after execution.",
        ),
        artifact_entry(
            artifact_id="qc_summary",
            title="ATAC QC Summary",
            path="qc/atacseq_qc_summary.tsv",
            kind="table",
            status="created",
            description="Parsed per-sample alignment, insert-size, FRiP, peak, TSS, motif, and track state.",
        ),
        artifact_entry(
            artifact_id="qc_dashboard",
            title="ATAC QC Dashboard",
            path="qc/atacseq_qc_dashboard.html",
            kind="html",
            status="created",
            description="Native dashboard summarizing FRiP, peak counts, insert sizes, track state, and caveats.",
        ),
        artifact_entry(
            artifact_id="frip_peak_overview",
            title="FRiP And Peak Plot",
            path="qc/atacseq_qc_frip_peak_overview.svg",
            kind="svg",
            status="created",
            description="Compact FRiP and peak-count plot generated from parsed run artifacts.",
        ),
        artifact_entry(
            artifact_id="insert_size_distribution",
            title="Insert-Size Plot",
            path="qc/atacseq_qc_insert_size_distribution.svg",
            kind="svg",
            status="created",
            description="Native insert-size distribution plot generated from parsed fragment sizes.",
        ),
        artifact_entry(
            artifact_id="browser_tracks",
            title="Browser Track Manifest",
            path="tracks/browser_tracks.tsv",
            kind="table",
            status="created",
            description="bigWig track lines and IGV/UCSC browser handoff metadata.",
        ),
        artifact_entry(
            artifact_id="browser_track_preview",
            title="Browser Track Preview",
            path="tracks/browser_track_preview.html",
            kind="html",
            status="created",
            description="HTML preview of bigWig track paths and UCSC track lines.",
        ),
        artifact_entry(
            artifact_id="consensus_peaks",
            title="Consensus Peaks",
            path="peaks/consensus_peaks.bed",
            kind="bed",
            status="created"
            if (run_dir / "peaks" / "consensus_peaks.bed").exists()
            else "not_available",
            description="Merged consensus peak set after execution.",
        ),
        artifact_entry(
            artifact_id="motif_summary",
            title="Motif Summary",
            path="motifs/motif_summary.tsv",
            kind="table",
            status="created",
            description="Motif-enrichment output summary when motif backend outputs are present.",
        ),
    ]
    entries.extend(ngs_resource_gate.resource_visual_entries(resource_plan))
    index = write_visualization_index(
        run_dir,
        title="ATAC-seq Peaks QC Review",
        description="Review surface for ATAC-seq alignment, peak, FRiP, TSS, and track outputs.",
        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",
    }


def write_summary(
    run_dir: Path,
    status: str,
    validation: dict[str, Any],
    resource_plan: dict[str, Any] | None = None,
) -> None:
    lines = [
        "# ATAC-seq Peaks QC Run Summary",
        "",
        f"Status: `{status}`",
        f"Samples parsed: `{validation.get('sample_count', 0)}`",
        "",
        "## Key Artifacts",
        "",
        "- `workflow/atacseq_command_plan.json`",
        "- `qc/atac_qc_contract.json`",
        "- `qc/atacseq_qc_summary.tsv` and `qc/atacseq_qc_summary.json`",
        "- `qc/atacseq_qc_dashboard.html`, `qc/atacseq_qc_frip_peak_overview.svg`, and `qc/atacseq_qc_insert_size_distribution.svg`",
        "- `peaks/*.narrowPeak`, `peaks/consensus_peaks.bed`, and `tracks/*.bw` when executed",
        "- `tracks/browser_tracks.tsv`, `tracks/browser_track_preview.html`, `tracks/ucsc_track_lines.txt`, and `tracks/igv_session.xml`",
        "- `motifs/motif_summary.tsv` when motif outputs are generated",
        "- `resources/resource_plan.json`, `resource_manifest.tsv`, `resource_env.sh`, `resource_readiness.md`, and resource setup-plan artifacts",
        "- `visualizations/index.html`",
        "- `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"])
    write_text(run_dir / "summary.md", "\n".join(lines) + "\n")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--sample-sheet", type=Path, required=True)
    parser.add_argument("--bowtie2-index")
    parser.add_argument("--bam-only", action="store_true")
    parser.add_argument("--genome-size", required=True)
    parser.add_argument("--blacklist-bed", type=Path)
    parser.add_argument("--tss-bed", type=Path)
    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 direct runner.",
    )
    parser.add_argument(
        "--skip-resource-plan",
        action="store_true",
        help="Skip registered reference bundle readiness checks.",
    )
    parser.add_argument("--run-motifs", action="store_true")
    parser.add_argument("--motif-genome")
    parser.add_argument("--motif-size", default="given")
    parser.add_argument("--min-mapq", type=int, default=30)
    parser.add_argument("--threads", type=int, default=4)
    parser.add_argument("--outdir", type=Path)
    parser.add_argument("--run-id", default=slug_timestamp("atacseq-peaks-qc"))
    parser.add_argument("--execute", action="store_true")
    return parser.parse_args()


def serializable_args(args: argparse.Namespace) -> dict[str, Any]:
    return {
        key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()
    }


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, samples = validate_inputs(args)
    resource_plan = ngs_resource_gate.write_pipeline_resource_plan(
        run_dir=run_dir,
        pipeline="atacseq_peaks_qc",
        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
    )
    needs_alignment = any(row["layout"].startswith("fastq") for row in samples)
    required_tools = (
        ["samtools", "macs2", "bedtools", "bamCoverage"] + (["bowtie2"] if needs_alignment else [])
        if args.execute
        else []
    )
    if args.execute and args.tss_bed:
        required_tools.extend(["computeMatrix", "plotProfile", "plotHeatmap"])
    if args.execute and args.run_motifs:
        required_tools.append("findMotifsGenome.pl")
    optional_tools = [
        name
        for name in [
            "samtools",
            "macs2",
            "bedtools",
            "bamCoverage",
            "bowtie2",
            "computeMatrix",
            "plotProfile",
            "plotHeatmap",
            "findMotifsGenome.pl",
            "multiqc",
        ]
        if name not in required_tools
    ]
    tool_status = tool_preflight(required_tools, optional=optional_tools)
    plan = build_plan(args, samples)
    write_json(run_dir / "config.json", {**serializable_args(args), "run_dir": str(run_dir)})
    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_json(
        run_dir / "versions" / "software_versions.json",
        software_versions(
            {
                "samtools": ["samtools", "--version"],
                "macs2": ["macs2", "--version"],
                "bedtools": ["bedtools", "--version"],
                "bowtie2": ["bowtie2", "--version"],
                "bamCoverage": ["bamCoverage", "--version"],
            }
        ),
    )
    write_outputs(run_dir, validation, samples, plan)
    dry_run = {
        "ok": validation["ok"] and (tool_status["ok"] if args.execute else True),
        "detail": "ATAC sample, metadata, and backend tool validation completed",
    }
    write_json(run_dir / "logs" / "validation_dry_run.json", dry_run)
    status = "blocked" if not dry_run["ok"] else "validated"
    execution = None
    if args.execute and dry_run["ok"]:
        execution = execute_plan(run_dir, plan)
        status = "completed" if execution.get("ok") else "failed"
        summarize_epigenomics_outputs(
            run_dir, samples, peak_mode="narrow", output_prefix="atacseq_qc", title="ATAC-seq"
        )
    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="atacseq_peaks_qc",
        workflow="local_light_atacseq_alignment_peaks_qc",
        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()),
            "blacklist_bed": str(args.blacklist_bed.expanduser().resolve())
            if args.blacklist_bed
            else None,
            "tss_bed": str(args.tss_bed.expanduser().resolve()) if args.tss_bed else None,
            **(
                {"resource_plan": resource_outputs.get("resource_plan")} if resource_outputs else {}
            ),
        },
        outputs={
            "sample_table": "validation/samples.normalized.tsv",
            "command_plan": "workflow/atacseq_command_plan.json",
            "qc_contract": "qc/atac_qc_contract.json",
            "qc_summary": "qc/atacseq_qc_summary.tsv",
            "qc_summary_json": "qc/atacseq_qc_summary.json",
            "qc_dashboard": "qc/atacseq_qc_dashboard.html",
            "frip_peak_overview": "qc/atacseq_qc_frip_peak_overview.svg",
            "insert_size_distribution": "qc/atacseq_qc_insert_size_distribution.svg",
            "peaks": "peaks/*.narrowPeak",
            "consensus_peaks": "peaks/consensus_peaks.bed",
            "tracks": "tracks/*.bw",
            "browser_tracks": "tracks/browser_tracks.tsv",
            "browser_track_preview": "tracks/browser_track_preview.html",
            "igv_session": "tracks/igv_session.xml",
            "motif_summary": "motifs/motif_summary.tsv",
            **resource_outputs,
            **visuals,
        },
        method={
            "peak_caller": "MACS2",
            "frip": "bedtools intersect + samtools count",
            "tss_enrichment": "deepTools computeMatrix/plotProfile/plotHeatmap when --tss-bed is supplied",
            "motif_enrichment": "HOMER findMotifsGenome.pl when --run-motifs is supplied",
            "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: d3f5934b56b961a2e3028bc7f03f6c5f5657cf95edf4bc56ad1d48f38194886c