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

197 lines
15 KiB
Python

#!/usr/bin/env python3
"""Count VCF SNP records in physical windows and publish verified density plots."""
from __future__ import annotations
import argparse
import gzip
import json
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
import time
import uuid
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, TextIO
from gfa_core.genotype_context import build_population_analysis_scope_id, genotype_scope_id_for_vcf, sha256_file
from gfa_core.population_stage_runtime import acquire_identity_lock, population_manifest_base, release_identity_lock, write_reproducibility_bundle
STAGE = "pop_snp_density"
METHOD_VERSION = "vcf_physical_windows_cmplot.v2"
CONTRACT_VERSION = "v1"
CM_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "pop_snp_density_cmplot.R"
def _open(path: Path) -> TextIO:
return gzip.open(path, "rt", encoding="utf-8") if path.suffix == ".gz" else path.open("r", encoding="utf-8")
def _rice_alias(value: str) -> str | None:
match = re.fullmatch(r"(?:chr)?0*([1-9]|1[0-2])", value.strip(), flags=re.IGNORECASE)
return str(int(match.group(1))) if match else None
def select_snp_records(path: Path, *, policy: str, explicit: list[str]) -> tuple[list[tuple[str, int, str, int]], dict[str, Any]]:
if policy not in {"all_vcf_contigs", "rice_1_12", "explicit"}:
raise ValueError("Invalid chromosome_policy")
if policy == "explicit" and not explicit:
raise ValueError("explicit chromosome policy requires chromosomes")
selected_tokens = {(_rice_alias(item) or item): item for item in explicit}
raw_records: list[tuple[str, int, str]] = []
input_record_count = 0
excluded_non_snp_count = 0
with _open(path) as handle:
for raw in handle:
if raw.startswith("#") or not raw.strip():
continue
fields = raw.rstrip("\n").split("\t")
if len(fields) < 5:
raise ValueError("VCF record has fewer than five columns")
input_record_count += 1
position = int(fields[1])
if position <= 0:
raise ValueError("VCF positions must be positive")
ref = fields[3].upper()
alts = [allele.upper() for allele in fields[4].split(",")]
if len(ref) != 1 or ref not in "ACGT" or not alts or any(len(allele) != 1 or allele not in "ACGT" for allele in alts):
excluded_non_snp_count += 1
continue
raw_records.append((fields[0], position, fields[2] if fields[2] != "." else f"{fields[0]}:{position}"))
canonical_sources: dict[str, set[str]] = {}
kept: list[tuple[str, int, str]] = []
for chrom, position, marker in raw_records:
if policy == "all_vcf_contigs":
canonical = chrom
else:
canonical = _rice_alias(chrom) or chrom
if policy == "rice_1_12" and _rice_alias(chrom) is None:
continue
if policy == "explicit" and canonical not in selected_tokens and chrom not in selected_tokens:
continue
canonical_sources.setdefault(canonical, set()).add(chrom)
kept.append((canonical, position, marker))
conflicts = {canonical: sorted(sources) for canonical, sources in canonical_sources.items() if len(sources) > 1}
if conflicts:
raise ValueError(f"Chromosome alias normalization collision: {conflicts}")
if not kept:
raise ValueError("Chromosome policy selected zero SNP records")
contigs = list(dict.fromkeys(chrom for chrom, _position, _marker in kept))
plot_index = {chrom: index for index, chrom in enumerate(contigs, start=1)}
records = [(chrom, position, marker, plot_index[chrom]) for chrom, position, marker in kept]
input_snp_count = len(raw_records)
return records, {
"input_record_count": input_record_count,
"input_snp_record_count": input_snp_count,
"excluded_non_snp_count": excluded_non_snp_count,
"selected_snp_count": len(records),
"excluded_by_chromosome_policy_count": input_snp_count - len(records),
"selected_chromosomes": contigs,
}
def build_density_rows(records: list[tuple[str, int, str, int]], window_bp: int) -> list[tuple[str, int, int, int]]:
if window_bp <= 0:
raise ValueError("Density window must be positive")
order = list(dict.fromkeys(record[0] for record in records))
counts = Counter((chrom, (position - 1) // window_bp) for chrom, position, _marker, _plot in records)
max_index = {
chrom: max((position - 1) // window_bp for chrom2, position, _marker, _plot in records if chrom2 == chrom)
for chrom in order
}
return [
(chrom, index * window_bp + 1, (index + 1) * window_bp, counts[(chrom, index)])
for chrom in order for index in range(max_index[chrom] + 1)
]
def _resolve(value: Path) -> Path:
path = value.resolve() if value.is_absolute() or value.exists() else Path(shutil.which(str(value)) or "")
if not path.is_file(): raise FileNotFoundError(f"Executable not found: {value}")
return path
def _artifact(workspace: Path, path: Path, role: str, media: str) -> dict[str, Any]:
if not path.is_file() or path.stat().st_size <= 0: raise ValueError(f"Required artifact is missing or empty: {role}")
size = path.stat().st_size
return {"artifact": role, "role": role, "path": str(path.relative_to(workspace)), "full_path": str(path.resolve()), "type": media, "size": size, "size_bytes": size, "sha256": sha256_file(path)}
def _publish(source: Path, destination: Path) -> None:
temp = destination.with_name(f".{destination.name}.{os.getpid()}.publishing"); shutil.copy2(source, temp); os.replace(temp, destination)
def _write_table(path: Path, rows: list[tuple[str, int, int, int]]) -> None:
path.write_text("chromosome\twindow_start\twindow_end\tsnp_count\n" + "".join(f"{chrom}\t{start}\t{end}\t{count}\n" for chrom, start, end, count in rows), encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workspace", required=True, type=Path); parser.add_argument("--job-id", required=True); parser.add_argument("--prefix", required=True); parser.add_argument("--vcf", required=True, type=Path)
parser.add_argument("--chromosome-policy", default="all_vcf_contigs", choices=("all_vcf_contigs", "rice_1_12", "explicit")); parser.add_argument("--chromosomes", default="")
parser.add_argument("--coarse-window-bp", default=1_000_000, type=int); parser.add_argument("--fine-window-bp", default=500_000, type=int); parser.add_argument("--rscript", default="Rscript", type=Path)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv); workspace = args.workspace.resolve(); final = workspace / "final"; final.mkdir(parents=True, exist_ok=True)
started = datetime.now(timezone.utc); clock = time.monotonic(); run_id = f"{STAGE}_{started.strftime('%Y%m%dT%H%M%SZ')}_{uuid.uuid4().hex[:8]}"; failed = final / f"{args.prefix}.{STAGE}.failed.{run_id}.manifest.json"; explicit = [item.strip() for item in args.chromosomes.split(",") if item.strip()]
identity12 = "unresolved"; runner_command = [sys.executable, "-m", "apps.cli.run_pop_snp_density", *(argv if argv is not None else sys.argv[1:])]
lock_handle = None
parameters = {"chromosome_policy": args.chromosome_policy, "chromosomes": explicit, "coarse_window_bp": args.coarse_window_bp, "fine_window_bp": args.fine_window_bp}
manifest = population_manifest_base(
workspace=workspace, stage=STAGE, prefix=args.prefix, job_id=args.job_id,
run_id=run_id, started=started, runner_command=runner_command,
parameters=parameters,
)
try:
vcf = args.vcf.resolve(); records, qc = select_snp_records(vcf, policy=args.chromosome_policy, explicit=explicit); coarse_rows = build_density_rows(records, args.coarse_window_bp); fine_rows = build_density_rows(records, args.fine_window_bp)
if sum(row[3] for row in coarse_rows) != len(records) or sum(row[3] for row in fine_rows) != len(records): raise ValueError("Density window count conservation failed")
rscript = _resolve(args.rscript); probe = subprocess.run([str(rscript), "--version"], text=True, capture_output=True, timeout=30)
if probe.returncode != 0: raise RuntimeError("Rscript version probe failed")
versions = {"rscript": (probe.stdout + probe.stderr).strip().splitlines()[0], "cmplot_script_sha256": sha256_file(CM_SCRIPT), "python": platform.python_version()}
genotype_scope = genotype_scope_id_for_vcf(vcf); input_hashes = {"population_vcf": sha256_file(vcf)}; analysis_scope = build_population_analysis_scope_id(stage=STAGE, genotype_scope_id=genotype_scope, input_artifact_hashes=input_hashes, parameters=parameters, method_version=METHOD_VERSION, contract_version=CONTRACT_VERSION, tool_versions=versions); identity12 = analysis_scope.split(":", 1)[1][:12]
lock_handle = acquire_identity_lock(workspace, STAGE, args.prefix, identity12)
manifest.update({"genotype_scope_id": genotype_scope, "analysis_scope_id": analysis_scope, "method_version": METHOD_VERSION, "contract_version": CONTRACT_VERSION, "tool_versions": versions, "inputs": [{"role": "population_vcf", "sha256": input_hashes["population_vcf"]}]})
attempt = workspace / ".attempts" / run_id; attempt.mkdir(parents=True, exist_ok=False)
coords = attempt / "snp_coordinates.tsv"; coords.write_text("SNP\tChromosome\tPosition\tPlotChromosome\n" + "".join(f"{marker}\t{chrom}\t{position}\t{plot}\n" for chrom, position, marker, plot in records), encoding="utf-8")
coarse_table, fine_table = attempt / "density_1mb.tsv", attempt / "density_500kb.tsv"; _write_table(coarse_table, coarse_rows); _write_table(fine_table, fine_rows)
coarse_plot, fine_plot = attempt / "density_1mb.png", attempt / "density_500kb.png"; command = [str(rscript), str(CM_SCRIPT), str(coords), str(args.coarse_window_bp), str(args.fine_window_bp), str(coarse_plot), str(fine_plot)]
result = subprocess.run(command, text=True, capture_output=True, cwd=attempt); (attempt / "cmplot.log").write_text("$ " + shlex.join(command) + "\n\nSTDOUT\n" + result.stdout + "\nSTDERR\n" + result.stderr, encoding="utf-8")
if result.returncode != 0: raise RuntimeError(f"CMplot command failed with exit code {result.returncode}")
if any(not path.is_file() or path.stat().st_size <= 0 for path in (coarse_plot, fine_plot)): raise ValueError("CMplot did not create both canonical density plots in the current attempt")
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
contigs = qc["selected_chromosomes"]; bins = sorted({start for _chrom, start, _end, _count in fine_rows}); matrix = [[next((count for chrom2, start2, _end, count in fine_rows if chrom2 == chrom and start2 == start), 0) for start in bins] for chrom in contigs]
heatmap = attempt / "density_heatmap_500kb.png"; fig, ax = plt.subplots(figsize=(max(7, len(bins) * 0.25), max(4, len(contigs) * 0.35))); image = ax.imshow(matrix, aspect="auto", interpolation="nearest"); ax.set_yticks(range(len(contigs))); ax.set_yticklabels(contigs); ax.set_xlabel("Physical 500 kb window start"); fig.colorbar(image, ax=ax, label="SNP count"); fig.tight_layout(); fig.savefig(heatmap, dpi=180); plt.close(fig)
chr_plot = attempt / "chromosome_snp_count.png"; chr_counts = Counter(record[0] for record in records); fig, ax = plt.subplots(figsize=(max(7, len(contigs) * 0.5), 5)); ax.bar(contigs, [chr_counts[chrom] for chrom in contigs]); ax.set_ylabel("SNP record count"); ax.tick_params(axis="x", rotation=90); fig.tight_layout(); fig.savefig(chr_plot, dpi=180); plt.close(fig)
report = attempt / "report.md"; report.write_text(f"# SNP density\n\n- Chromosome policy: {args.chromosome_policy}\n- Selected SNP records: {len(records)}\n- Physical windows: {args.coarse_window_bp} bp and {args.fine_window_bp} bp\n- Both density tables passed count conservation.\n", encoding="utf-8")
environment = attempt / "environment.txt"; environment.write_text("\n".join(f"{key}={value}" for key, value in versions.items()) + "\n", encoding="utf-8")
outputs = {"snp_coordinates": (coords, "coordinates.tsv", "text/tab-separated-values"), "density_1mb_table": (coarse_table, "density_1mb.tsv", "text/tab-separated-values"), "density_500kb_table": (fine_table, "density_500kb.tsv", "text/tab-separated-values"), "density_1mb_plot": (coarse_plot, "density_1mb.png", "image/png"), "density_500kb_plot": (fine_plot, "density_500kb.png", "image/png"), "density_heatmap_500kb": (heatmap, "heatmap_500kb.png", "image/png"), "chromosome_snp_count_plot": (chr_plot, "chromosome_counts.png", "image/png"), "report": (report, "report.md", "text/markdown"), "environment": (environment, "environment.txt", "text/plain")}
stem = f"{args.prefix}.{STAGE}.{identity12}.{run_id}"; published: dict[str, tuple[Path, str]] = {}
for role, (source, suffix, media) in outputs.items(): destination = final / f"{stem}.{suffix}"; _publish(source, destination); published[role] = (destination, media)
manifest.update({"status": "success", "metrics": {**qc, "density_1mb_count_sum": sum(row[3] for row in coarse_rows), "density_500kb_count_sum": sum(row[3] for row in fine_rows)}, "commands": [command], "artifacts": {role: _artifact(workspace, path, role, media) for role, (path, media) in published.items()}}); manifest["end_time"] = datetime.now(timezone.utc).isoformat(); manifest["elapsed_sec"] = round(time.monotonic() - clock, 6)
work = attempt / "manifest.json"; work.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"); manifest_path = final / f"{stem}.manifest.json"
write_reproducibility_bundle(workspace=workspace, stage=STAGE, prefix=args.prefix, identity12=identity12, run_id=run_id, runner_command=runner_command, manifest_source=work, public_manifest=manifest_path, manifest=manifest, environment=environment.read_text(encoding="utf-8"), artifact_sources={role: source for role, (source, _suffix, _media) in outputs.items()})
_publish(work, manifest_path); return 0
except Exception as exc:
manifest.update({"status": "failed", "artifacts": {}, "error": str(exc), "end_time": datetime.now(timezone.utc).isoformat(), "elapsed_sec": round(time.monotonic() - clock, 6)}); failed.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
try:
write_reproducibility_bundle(workspace=workspace, stage=STAGE, prefix=args.prefix, identity12=identity12, run_id=run_id, runner_command=runner_command, manifest_source=failed, public_manifest=failed, manifest=manifest, environment=json.dumps(manifest.get("tool_versions", {}), ensure_ascii=False))
except Exception as bundle_exc:
manifest["reproducibility_error"] = str(bundle_exc); failed.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"ERROR: {exc}", file=sys.stderr); return 1
finally:
release_identity_lock(lock_handle)
if __name__ == "__main__": raise SystemExit(main())