From 63aaef99a9e49eaeed190081220b9945d635a79a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 06:18:18 +0000 Subject: [PATCH 1/3] feat: add NGS pipeline modules and CLI commands Agent-Logs-Url: https://github.com/SirLearning/CropAbility/sessions/5ba87a6e-4679-446f-bdbe-721444c24782 Co-authored-by: SirLearning <74081090+SirLearning@users.noreply.github.com> --- README.md | 8 ++ cropability/cli/main.py | 89 +++++++++++++ cropability/genomics/__init__.py | 13 ++ cropability/genomics/fastcall3.py | 144 ++++++++++++++++++++ cropability/genomics/pileup.py | 211 ++++++++++++++++++++++++++++++ cropability/genomics/pipeline.py | 190 +++++++++++++++++++++++++++ cropability/io/__init__.py | 3 + cropability/io/bam.py | 120 +++++++++++++++++ cropability/io/vcf.py | 23 +++- tests/test_ngs_pipeline.py | 87 ++++++++++++ 10 files changed, 885 insertions(+), 3 deletions(-) create mode 100644 cropability/genomics/fastcall3.py create mode 100644 cropability/genomics/pileup.py create mode 100644 cropability/genomics/pipeline.py create mode 100644 cropability/io/bam.py create mode 100644 tests/test_ngs_pipeline.py diff --git a/README.md b/README.md index 1f13ef1..2bfdaec 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,19 @@ cropability snp -i samples.fa --min-af 0.05 --min-depth 10 # 计算 LD 矩阵 cropability ld --n-samples 500 --n-snps 1000 +# 运行 mpileup(真实 BAM/CRAM 输入) +cropability pileup -r ref.fa -b sample1.bam sample2.bam -o cohort.mpileup + +# 运行变异检测流程(默认 hybrid: mpileup + FastCall3) +cropability call-variants -r ref.fa -b sample1.bam sample2.bam -o cohort.vcf --mode hybrid + # 导出 TorchScript 模型(供 Java 调用) cropability export --model add --output model.pt cropability export --model embedding --output embed.pt ``` +> NGS 流程依赖外部工具:`samtools`(必需),`FastCall3`(`fastcall3`/`hybrid` 模式必需)。 + ### Python API ```python diff --git a/cropability/cli/main.py b/cropability/cli/main.py index ade3ddc..3f7caa2 100644 --- a/cropability/cli/main.py +++ b/cropability/cli/main.py @@ -9,6 +9,8 @@ ld — 计算连锁不平衡矩阵 gwas — 全基因组关联分析 align — 批量序列比对 + pileup — 运行 samtools mpileup + call-variants — 运行 NGS 变异检测流程 export — 导出 TorchScript 模型 benchmark — GPU 性能基准测试 """ @@ -199,6 +201,60 @@ def cmd_ld(args: argparse.Namespace) -> int: return 0 +def cmd_pileup(args: argparse.Namespace) -> int: + """运行 samtools mpileup 并输出 pileup 文本。""" + from cropability.genomics.pipeline import QCThresholds, VariantPipeline + + qc = QCThresholds( + min_depth=args.min_depth, + min_base_quality=args.min_baseq, + min_mapping_quality=args.min_mapq, + min_alt_freq=args.min_af, + ) + pipeline = VariantPipeline() + report = pipeline.run( + mode="mpileup", + reference=args.reference, + bam_files=args.bam, + output=args.output, + qc=qc, + regions=args.region, + dry_run=args.dry_run, + ) + print("mpileup completed") + print(f" output: {report['mpileup']['output']}") + return 0 + + +def cmd_call_variants(args: argparse.Namespace) -> int: + """运行变异检测流程(mpileup / fastcall3 / hybrid)。""" + from cropability.genomics.pipeline import QCThresholds, VariantPipeline + + qc = QCThresholds( + min_depth=args.min_depth, + min_base_quality=args.min_baseq, + min_mapping_quality=args.min_mapq, + min_alt_freq=args.min_af, + ) + pipeline = VariantPipeline() + report = pipeline.run( + mode=args.mode, + reference=args.reference, + bam_files=args.bam, + output=args.output, + qc=qc, + regions=args.region, + mpileup_output=args.mpileup_output, + dry_run=args.dry_run, + ) + print(f"pipeline mode={report['mode']} completed") + if "mpileup" in report: + print(f" mpileup: {report['mpileup']['output']}") + if "fastcall3" in report: + print(f" vcf: {report['fastcall3']['output']}") + return 0 + + # --------------------------------------------------------------------------- # 主解析器 # --------------------------------------------------------------------------- @@ -243,6 +299,37 @@ def build_parser() -> argparse.ArgumentParser: ld.add_argument("--n-samples", type=int, default=200) ld.add_argument("--n-snps", type=int, default=500) + # pileup + pileup = sub.add_parser("pileup", help="运行 samtools mpileup") + pileup.add_argument("-r", "--reference", required=True, help="参考基因组 FASTA") + pileup.add_argument("-b", "--bam", required=True, nargs="+", help="输入 BAM/CRAM 文件列表") + pileup.add_argument("-o", "--output", required=True, help="输出 mpileup 文本路径") + pileup.add_argument("--region", default=None, help="区域过滤,如 chr1:1-100000") + pileup.add_argument("--min-depth", type=int, default=10, help="最小深度阈值") + pileup.add_argument("--min-baseq", type=int, default=20, help="最小碱基质量") + pileup.add_argument("--min-mapq", type=int, default=20, help="最小比对质量") + pileup.add_argument("--min-af", type=float, default=0.05, help="最小替代等位频率") + pileup.add_argument("--dry-run", action="store_true", help="仅打印命令,不实际执行") + + # call-variants + call = sub.add_parser("call-variants", help="运行变异检测流程") + call.add_argument("-r", "--reference", required=True, help="参考基因组 FASTA") + call.add_argument("-b", "--bam", required=True, nargs="+", help="输入 BAM/CRAM 文件列表") + call.add_argument("-o", "--output", required=True, help="输出 VCF(或 mpileup)路径") + call.add_argument( + "--mode", + choices=["mpileup", "fastcall3", "hybrid"], + default="hybrid", + help="流程模式", + ) + call.add_argument("--mpileup-output", default=None, help="hybrid 模式的中间 mpileup 输出路径") + call.add_argument("--region", default=None, help="区域过滤,如 chr1:1-100000") + call.add_argument("--min-depth", type=int, default=10, help="最小深度阈值") + call.add_argument("--min-baseq", type=int, default=20, help="最小碱基质量") + call.add_argument("--min-mapq", type=int, default=20, help="最小比对质量") + call.add_argument("--min-af", type=float, default=0.05, help="最小替代等位频率") + call.add_argument("--dry-run", action="store_true", help="仅打印命令,不实际执行") + return parser @@ -252,6 +339,8 @@ def build_parser() -> argparse.ArgumentParser: "snp": cmd_snp, "export": cmd_export, "ld": cmd_ld, + "pileup": cmd_pileup, + "call-variants": cmd_call_variants, } diff --git a/cropability/genomics/__init__.py b/cropability/genomics/__init__.py index 7d0d71d..1775787 100644 --- a/cropability/genomics/__init__.py +++ b/cropability/genomics/__init__.py @@ -13,6 +13,9 @@ from cropability.genomics.ld import LDCalculator, LDResult from cropability.genomics.gwas import GWASEngine, GWASResult from cropability.genomics.alignment import SmithWatermanGPU +from cropability.genomics.fastcall3 import FastCall3Runner, FastCall3Config, FastCall3RunResult +from cropability.genomics.pileup import MpileupParser, PileupRecord, PileupSample, PileupSiteSummary +from cropability.genomics.pipeline import VariantPipeline, PipelineConfig, QCThresholds __all__ = [ "VariantCaller", @@ -22,4 +25,14 @@ "GWASEngine", "GWASResult", "SmithWatermanGPU", + "FastCall3Runner", + "FastCall3Config", + "FastCall3RunResult", + "MpileupParser", + "PileupRecord", + "PileupSample", + "PileupSiteSummary", + "VariantPipeline", + "PipelineConfig", + "QCThresholds", ] diff --git a/cropability/genomics/fastcall3.py b/cropability/genomics/fastcall3.py new file mode 100644 index 0000000..90189de --- /dev/null +++ b/cropability/genomics/fastcall3.py @@ -0,0 +1,144 @@ +""" +FastCall3 外部调用适配层 +======================== +将 FastCall3 作为外部程序纳入 CropAbility 流水线,负责参数映射与执行。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Union +import shutil +import subprocess + +from cropability.utils.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass +class FastCall3Config: + executable: str = "FastCall3" + timeout_seconds: int = 3600 + extra_args: List[str] = field(default_factory=list) + + +@dataclass +class FastCall3RunResult: + command: List[str] + returncode: int + stdout: str + stderr: str + output_vcf: Path + + @property + def ok(self) -> bool: + return self.returncode == 0 and self.output_vcf.exists() + + +class FastCall3Runner: + """FastCall3 适配执行器。""" + + def __init__(self, config: Optional[FastCall3Config] = None) -> None: + self.config = config or FastCall3Config() + + def validate_executable(self) -> str: + resolved = shutil.which(self.config.executable) + if resolved is None: + p = Path(self.config.executable) + if not p.exists(): + raise RuntimeError( + f"FastCall3 executable not found: {self.config.executable}" + ) + resolved = str(p) + return resolved + + def build_command( + self, + reference: Union[str, Path], + bam_files: Sequence[Union[str, Path]], + output_vcf: Union[str, Path], + regions: Optional[str] = None, + min_base_quality: Optional[int] = None, + min_mapping_quality: Optional[int] = None, + min_depth: Optional[int] = None, + extra_args: Optional[Sequence[str]] = None, + ) -> List[str]: + exe = self.validate_executable() + cmd: List[str] = [exe, "-r", str(reference), "-o", str(output_vcf)] + for bam in bam_files: + cmd.extend(["-i", str(bam)]) + if regions: + cmd.extend(["-R", regions]) + if min_base_quality is not None: + cmd.extend(["--min-base-qual", str(min_base_quality)]) + if min_mapping_quality is not None: + cmd.extend(["--min-mapq", str(min_mapping_quality)]) + if min_depth is not None: + cmd.extend(["--min-depth", str(min_depth)]) + cmd.extend(self.config.extra_args) + if extra_args: + cmd.extend([str(x) for x in extra_args]) + return cmd + + def run( + self, + reference: Union[str, Path], + bam_files: Sequence[Union[str, Path]], + output_vcf: Union[str, Path], + regions: Optional[str] = None, + min_base_quality: Optional[int] = None, + min_mapping_quality: Optional[int] = None, + min_depth: Optional[int] = None, + extra_args: Optional[Sequence[str]] = None, + dry_run: bool = False, + ) -> FastCall3RunResult: + output_path = Path(output_vcf) + cmd = self.build_command( + reference=reference, + bam_files=bam_files, + output_vcf=output_path, + regions=regions, + min_base_quality=min_base_quality, + min_mapping_quality=min_mapping_quality, + min_depth=min_depth, + extra_args=extra_args, + ) + logger.info("Running FastCall3: %s", " ".join(cmd)) + + if dry_run: + return FastCall3RunResult( + command=cmd, + returncode=0, + stdout="", + stderr="", + output_vcf=output_path, + ) + + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.config.timeout_seconds, + check=False, + ) + + result = FastCall3RunResult( + command=cmd, + returncode=proc.returncode, + stdout=proc.stdout, + stderr=proc.stderr, + output_vcf=output_path, + ) + + if proc.returncode != 0: + raise RuntimeError( + f"FastCall3 failed with exit code {proc.returncode}: {proc.stderr.strip()}" + ) + if not output_path.exists(): + raise RuntimeError( + f"FastCall3 completed but output VCF not found: {output_path}" + ) + return result + diff --git a/cropability/genomics/pileup.py b/cropability/genomics/pileup.py new file mode 100644 index 0000000..469fdb3 --- /dev/null +++ b/cropability/genomics/pileup.py @@ -0,0 +1,211 @@ +""" +mpileup 解析与标准化 +=================== +提供对 samtools mpileup 文本输出的解析、计数统计与位点摘要能力。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterator, List, Optional, Sequence, Tuple, Union + +from cropability.utils.logging import get_logger + +logger = get_logger(__name__) + +_BASES = ("A", "C", "G", "T", "N") + + +@dataclass +class PileupSample: + """单样本位点统计。""" + + depth: int + base_counts: Dict[str, int] + insertions: int = 0 + deletions: int = 0 + + @property + def alt_count(self) -> int: + return sum(v for k, v in self.base_counts.items() if k in {"A", "C", "G", "T"}) + + +@dataclass +class PileupRecord: + """单个位点记录。""" + + chrom: str + pos: int + ref_base: str + samples: Dict[str, PileupSample] = field(default_factory=dict) + + def total_depth(self) -> int: + return sum(s.depth for s in self.samples.values()) + + +@dataclass +class PileupSiteSummary: + """跨样本位点汇总。""" + + chrom: str + pos: int + ref_base: str + depth: int + alt_base: Optional[str] + alt_count: int + alt_freq: float + + +def _parse_pileup_bases(bases: str, ref_base: str) -> Tuple[Dict[str, int], int, int]: + counts = {b: 0 for b in _BASES} + insertions = 0 + deletions = 0 + i = 0 + ref_u = ref_base.upper() + + while i < len(bases): + c = bases[i] + if c == "^": + i += 2 + continue + if c == "$": + i += 1 + continue + if c in "+-": + sign = c + i += 1 + nbuf = [] + while i < len(bases) and bases[i].isdigit(): + nbuf.append(bases[i]) + i += 1 + length = int("".join(nbuf)) if nbuf else 0 + if sign == "+": + insertions += 1 + else: + deletions += 1 + i += length + continue + if c == "*": + deletions += 1 + i += 1 + continue + if c in ".,": # 与参考一致 + if ref_u in counts: + counts[ref_u] += 1 + else: + counts["N"] += 1 + i += 1 + continue + + b = c.upper() + if b in counts: + counts[b] += 1 + else: + counts["N"] += 1 + i += 1 + + return counts, insertions, deletions + + +class MpileupParser: + """ + mpileup 文本解析器。 + + mpileup 格式: + CHROM POS REF [DP BASES QUAL]... + 每个样本占 3 列。 + """ + + def __init__(self, sample_names: Optional[Sequence[str]] = None) -> None: + self.sample_names = list(sample_names) if sample_names is not None else None + + def parse_line(self, line: str) -> Optional[PileupRecord]: + line = line.rstrip("\n") + if not line: + return None + cols = line.split("\t") + if len(cols) < 6: + return None + if (len(cols) - 3) % 3 != 0: + return None + + chrom, pos_s, ref_base = cols[0], cols[1], cols[2] + per_sample = cols[3:] + n_samples = len(per_sample) // 3 + if self.sample_names is None: + names = [f"sample{i + 1}" for i in range(n_samples)] + else: + if len(self.sample_names) != n_samples: + raise ValueError( + f"sample_names count ({len(self.sample_names)}) != mpileup samples ({n_samples})" + ) + names = list(self.sample_names) + + samples: Dict[str, PileupSample] = {} + for i, name in enumerate(names): + depth = int(per_sample[i * 3]) + bases = per_sample[i * 3 + 1] + counts, ins, dels = _parse_pileup_bases(bases, ref_base) + samples[name] = PileupSample( + depth=depth, + base_counts=counts, + insertions=ins, + deletions=dels, + ) + + return PileupRecord( + chrom=chrom, + pos=int(pos_s), + ref_base=ref_base.upper(), + samples=samples, + ) + + def parse_file(self, path: Union[str, Path]) -> Iterator[PileupRecord]: + p = Path(path) + with p.open("r", encoding="utf-8") as f: + for line in f: + rec = self.parse_line(line) + if rec is not None: + yield rec + + def summarize_sites( + self, + records: Iterator[PileupRecord], + min_depth: int = 10, + min_alt_freq: float = 0.05, + ) -> List[PileupSiteSummary]: + summaries: List[PileupSiteSummary] = [] + for rec in records: + merged_counts = {b: 0 for b in _BASES} + depth = 0 + for sample in rec.samples.values(): + depth += sample.depth + for b, c in sample.base_counts.items(): + merged_counts[b] = merged_counts.get(b, 0) + c + + if depth < min_depth: + continue + ref = rec.ref_base + alt_candidates = {b: c for b, c in merged_counts.items() if b in {"A", "C", "G", "T"} and b != ref} + if not alt_candidates: + continue + alt_base, alt_count = max(alt_candidates.items(), key=lambda kv: kv[1]) + alt_freq = alt_count / max(depth, 1) + if alt_freq < min_alt_freq: + continue + summaries.append( + PileupSiteSummary( + chrom=rec.chrom, + pos=rec.pos, + ref_base=ref, + depth=depth, + alt_base=alt_base, + alt_count=alt_count, + alt_freq=alt_freq, + ) + ) + + logger.info(f"Generated {len(summaries)} pileup site summaries") + return summaries + diff --git a/cropability/genomics/pipeline.py b/cropability/genomics/pipeline.py new file mode 100644 index 0000000..26be02e --- /dev/null +++ b/cropability/genomics/pipeline.py @@ -0,0 +1,190 @@ +""" +NGS 变异检测流程编排 +==================== +统一管理 mpileup / FastCall3 / hybrid 三种调用模式。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Union +import shutil +import subprocess +import time + +from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner +from cropability.io.bam import AlignmentInputManager +from cropability.utils.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass +class QCThresholds: + min_depth: int = 10 + min_base_quality: int = 20 + min_mapping_quality: int = 20 + min_alt_freq: float = 0.05 + + +@dataclass +class PipelineConfig: + samtools_executable: str = "samtools" + bcftools_executable: str = "bcftools" + fastcall3: FastCall3Config = field(default_factory=FastCall3Config) + timeout_seconds: int = 3600 + + +class VariantPipeline: + """NGS 变异检测流程。""" + + def __init__(self, config: Optional[PipelineConfig] = None) -> None: + self.config = config or PipelineConfig() + self.fastcall3_runner = FastCall3Runner(self.config.fastcall3) + + def _resolve_executable(self, exe: str) -> str: + resolved = shutil.which(exe) + if resolved is None: + raise RuntimeError(f"Executable not found in PATH: {exe}") + return resolved + + def run_mpileup( + self, + reference: Union[str, Path], + bam_files: Sequence[Union[str, Path]], + output_path: Union[str, Path], + qc: QCThresholds, + regions: Optional[str] = None, + extra_args: Optional[Sequence[str]] = None, + dry_run: bool = False, + ) -> Dict[str, object]: + samtools = self._resolve_executable(self.config.samtools_executable) + out = Path(output_path) + + cmd: List[str] = [ + samtools, + "mpileup", + "-f", + str(reference), + "-q", + str(qc.min_mapping_quality), + "-Q", + str(qc.min_base_quality), + ] + if regions: + cmd.extend(["-r", regions]) + if extra_args: + cmd.extend([str(x) for x in extra_args]) + cmd.extend([str(b) for b in bam_files]) + + logger.info("Running mpileup: %s", " ".join(cmd)) + if dry_run: + return {"command": cmd, "output": str(out), "returncode": 0} + + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("w", encoding="utf-8") as f: + proc = subprocess.run( + cmd, + stdout=f, + stderr=subprocess.PIPE, + text=True, + timeout=self.config.timeout_seconds, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"samtools mpileup failed with exit code {proc.returncode}: {proc.stderr.strip()}" + ) + return {"command": cmd, "output": str(out), "returncode": proc.returncode} + + def run_fastcall3( + self, + reference: Union[str, Path], + bam_files: Sequence[Union[str, Path]], + output_vcf: Union[str, Path], + qc: QCThresholds, + regions: Optional[str] = None, + extra_args: Optional[Sequence[str]] = None, + dry_run: bool = False, + ) -> Dict[str, object]: + result = self.fastcall3_runner.run( + reference=reference, + bam_files=bam_files, + output_vcf=output_vcf, + regions=regions, + min_base_quality=qc.min_base_quality, + min_mapping_quality=qc.min_mapping_quality, + min_depth=qc.min_depth, + extra_args=extra_args, + dry_run=dry_run, + ) + return { + "command": result.command, + "output": str(result.output_vcf), + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + def run( + self, + mode: str, + reference: Union[str, Path], + bam_files: Sequence[Union[str, Path]], + output: Union[str, Path], + qc: Optional[QCThresholds] = None, + regions: Optional[str] = None, + mpileup_output: Optional[Union[str, Path]] = None, + dry_run: bool = False, + ) -> Dict[str, object]: + mode = mode.lower() + if mode not in {"mpileup", "fastcall3", "hybrid"}: + raise ValueError("mode must be one of: mpileup, fastcall3, hybrid") + + qc = qc or QCThresholds() + AlignmentInputManager(bam_files).validate_tools(require_samtools=(mode in {"mpileup", "hybrid"})) + alignment_files = AlignmentInputManager(bam_files).collect(require_index=True) + bam_paths = [f.path for f in alignment_files] + + start = time.time() + report: Dict[str, object] = {"mode": mode, "reference": str(reference)} + if mode == "mpileup": + report["mpileup"] = self.run_mpileup( + reference=reference, + bam_files=bam_paths, + output_path=output, + qc=qc, + regions=regions, + dry_run=dry_run, + ) + elif mode == "fastcall3": + report["fastcall3"] = self.run_fastcall3( + reference=reference, + bam_files=bam_paths, + output_vcf=output, + qc=qc, + regions=regions, + dry_run=dry_run, + ) + else: + mpileup_out = Path(mpileup_output) if mpileup_output is not None else Path(f"{output}.mpileup") + report["mpileup"] = self.run_mpileup( + reference=reference, + bam_files=bam_paths, + output_path=mpileup_out, + qc=qc, + regions=regions, + dry_run=dry_run, + ) + report["fastcall3"] = self.run_fastcall3( + reference=reference, + bam_files=bam_paths, + output_vcf=output, + qc=qc, + regions=regions, + dry_run=dry_run, + ) + + report["elapsed_seconds"] = time.time() - start + return report diff --git a/cropability/io/__init__.py b/cropability/io/__init__.py index 384a703..7eaed29 100644 --- a/cropability/io/__init__.py +++ b/cropability/io/__init__.py @@ -1,9 +1,12 @@ """基因组数据 I/O 模块:FASTA/FASTQ/VCF 读写与格式转换。""" +from cropability.io.bam import AlignmentFile, AlignmentInputManager from cropability.io.fasta import FastaReader, FastaWriter from cropability.io.vcf import VCFReader, VCFWriter, VCFRecord __all__ = [ + "AlignmentFile", + "AlignmentInputManager", "FastaReader", "FastaWriter", "VCFReader", diff --git a/cropability/io/bam.py b/cropability/io/bam.py new file mode 100644 index 0000000..23f8a6e --- /dev/null +++ b/cropability/io/bam.py @@ -0,0 +1,120 @@ +""" +BAM/CRAM 输入抽象 +================= +为 NGS 变异检测流水线提供 BAM/CRAM 文件检查、样本命名与索引校验功能。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Tuple, Union +import shutil + +from cropability.utils.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass +class AlignmentFile: + """单个对齐输入文件信息。""" + + path: Path + sample_name: str + fmt: str # bam | cram + has_index: bool + index_path: Optional[Path] + + @property + def is_bam(self) -> bool: + return self.fmt == "bam" + + @property + def is_cram(self) -> bool: + return self.fmt == "cram" + + +def _detect_format(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".bam": + return "bam" + if suffix == ".cram": + return "cram" + raise ValueError(f"Unsupported alignment format for: {path} (expected .bam/.cram)") + + +def _resolve_index(path: Path, fmt: str) -> Optional[Path]: + if fmt == "bam": + candidates = [Path(f"{path}.bai"), path.with_suffix(".bai")] + else: + candidates = [Path(f"{path}.crai"), path.with_suffix(".crai")] + for idx in candidates: + if idx.exists(): + return idx + return None + + +class AlignmentInputManager: + """ + BAM/CRAM 输入管理器。 + + 支持: + - 基础文件存在性校验 + - BAM/CRAM 格式检测 + - 索引文件存在性校验 + - samtools / optional pysam 依赖检查 + """ + + def __init__(self, paths: Sequence[Union[str, Path]]) -> None: + if not paths: + raise ValueError("At least one alignment file is required") + self.paths = [Path(p) for p in paths] + + @staticmethod + def check_samtools() -> bool: + return shutil.which("samtools") is not None + + @staticmethod + def check_pysam() -> bool: + try: + import pysam # noqa: F401 + + return True + except Exception: + return False + + def validate_tools( + self, + require_samtools: bool = True, + require_pysam: bool = False, + ) -> None: + if require_samtools and not self.check_samtools(): + raise RuntimeError("samtools is required but not found in PATH") + if require_pysam and not self.check_pysam(): + raise RuntimeError("pysam is required but not installed") + + def collect(self, require_index: bool = True) -> List[AlignmentFile]: + files: List[AlignmentFile] = [] + for p in self.paths: + if not p.exists(): + raise FileNotFoundError(f"Alignment file not found: {p}") + fmt = _detect_format(p) + idx = _resolve_index(p, fmt) + if require_index and idx is None: + ext = ".bai" if fmt == "bam" else ".crai" + raise FileNotFoundError( + f"Index not found for {p}. Expected {p}{ext} or sibling index file." + ) + files.append( + AlignmentFile( + path=p, + sample_name=p.stem, + fmt=fmt, + has_index=idx is not None, + index_path=idx, + ) + ) + logger.info(f"Collected {len(files)} alignment files") + return files + diff --git a/cropability/io/vcf.py b/cropability/io/vcf.py index d5e03c6..ee13cb5 100644 --- a/cropability/io/vcf.py +++ b/cropability/io/vcf.py @@ -182,11 +182,22 @@ def __init__(self, path: Union[str, Path], sample_names: Optional[List[str]] = N self.sample_names = sample_names or [] self._f = self.path.open("w", encoding="utf-8") self._wrote_header = False - - def write_header(self, extra_meta: Optional[List[str]] = None) -> None: - self._f.write("##fileformat=VCFv4.2\n") + self._meta_lines: List[str] = [] + + def write_header( + self, + extra_meta: Optional[List[str]] = None, + source: Optional[str] = None, + fileformat: str = "VCFv4.2", + ) -> None: + self._f.write(f"##fileformat={fileformat}\n") + if source: + meta = f"##source={source}" + self._f.write(meta + "\n") + self._meta_lines.append(meta) for line in (extra_meta or []): self._f.write(line + "\n") + self._meta_lines.append(line) cols = ["#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO"] if self.sample_names: cols += ["FORMAT"] + self.sample_names @@ -208,6 +219,12 @@ def write_record(self, rec: VCFRecord) -> None: if rec.format: cols.append(":".join(rec.format)) cols.extend(rec.samples) + elif self.sample_names: + cols.append("GT") + if rec.samples: + cols.extend(rec.samples) + else: + cols.extend(["./."] * len(self.sample_names)) self._f.write("\t".join(cols) + "\n") def close(self) -> None: diff --git a/tests/test_ngs_pipeline.py b/tests/test_ngs_pipeline.py new file mode 100644 index 0000000..b0b8c6b --- /dev/null +++ b/tests/test_ngs_pipeline.py @@ -0,0 +1,87 @@ +"""测试 NGS pipeline 新增模块。""" + +from pathlib import Path + +import pytest + +from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner +from cropability.genomics.pileup import MpileupParser +from cropability.genomics.pipeline import QCThresholds, VariantPipeline +from cropability.io.bam import AlignmentInputManager + + +class TestAlignmentInputManager: + def test_collect_bam_with_index(self, tmp_path): + bam = tmp_path / "sample1.bam" + bai = tmp_path / "sample1.bam.bai" + bam.write_bytes(b"") + bai.write_bytes(b"") + mgr = AlignmentInputManager([bam]) + files = mgr.collect(require_index=True) + assert len(files) == 1 + assert files[0].is_bam + assert files[0].has_index + + def test_missing_index_raises(self, tmp_path): + bam = tmp_path / "sample1.bam" + bam.write_bytes(b"") + mgr = AlignmentInputManager([bam]) + with pytest.raises(FileNotFoundError): + mgr.collect(require_index=True) + + +class TestMpileupParser: + def test_parse_line_and_summary(self): + parser = MpileupParser(sample_names=["s1"]) + # chr1 10 A depth=5 bases=".,TtA" quals dummy + rec = parser.parse_line("chr1\t10\tA\t5\t.,TtA\tFFFFF") + assert rec is not None + assert rec.chrom == "chr1" + assert rec.pos == 10 + assert rec.samples["s1"].depth == 5 + summaries = parser.summarize_sites(iter([rec]), min_depth=1, min_alt_freq=0.1) + assert len(summaries) == 1 + assert summaries[0].alt_base in {"T", "C", "G", "A"} + + +class TestFastCall3Runner: + def test_build_and_dry_run(self, monkeypatch, tmp_path): + monkeypatch.setattr("shutil.which", lambda x: f"/usr/bin/{x}") + out = tmp_path / "out.vcf" + runner = FastCall3Runner(FastCall3Config(executable="FastCall3")) + result = runner.run( + reference="ref.fa", + bam_files=["a.bam", "b.bam"], + output_vcf=out, + regions="chr1:1-100", + min_base_quality=20, + min_mapping_quality=30, + min_depth=10, + dry_run=True, + ) + assert result.returncode == 0 + assert "-r" in result.command + assert "-o" in result.command + assert "--min-base-qual" in result.command + + +class TestVariantPipeline: + def test_run_hybrid_dry_run(self, monkeypatch, tmp_path): + monkeypatch.setattr("shutil.which", lambda x: f"/usr/bin/{x}") + bam = tmp_path / "cohort.bam" + bai = tmp_path / "cohort.bam.bai" + bam.write_bytes(b"") + bai.write_bytes(b"") + + pipeline = VariantPipeline() + report = pipeline.run( + mode="hybrid", + reference="ref.fa", + bam_files=[bam], + output=tmp_path / "cohort.vcf", + qc=QCThresholds(), + dry_run=True, + ) + assert report["mode"] == "hybrid" + assert "mpileup" in report + assert "fastcall3" in report From 7b1c9105bf2ffa8edc12eba8b4902d74c87cf840 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 06:20:39 +0000 Subject: [PATCH 2/3] test: refine pipeline modules and add CLI parsing coverage Agent-Logs-Url: https://github.com/SirLearning/CropAbility/sessions/5ba87a6e-4679-446f-bdbe-721444c24782 Co-authored-by: SirLearning <74081090+SirLearning@users.noreply.github.com> --- cropability/genomics/__init__.py | 10 +++---- cropability/genomics/fastcall3.py | 49 +++++++++++++++--------------- cropability/genomics/pileup.py | 23 +++++++------- cropability/genomics/pipeline.py | 50 +++++++++++++++---------------- cropability/io/__init__.py | 2 +- cropability/io/bam.py | 15 +++++----- tests/test_ngs_pipeline.py | 23 +++++++++++++- 7 files changed, 95 insertions(+), 77 deletions(-) diff --git a/cropability/genomics/__init__.py b/cropability/genomics/__init__.py index 1775787..561f20d 100644 --- a/cropability/genomics/__init__.py +++ b/cropability/genomics/__init__.py @@ -9,13 +9,13 @@ - alignment: 序列比对评分 """ -from cropability.genomics.variant import VariantCaller, SNPResult -from cropability.genomics.ld import LDCalculator, LDResult -from cropability.genomics.gwas import GWASEngine, GWASResult from cropability.genomics.alignment import SmithWatermanGPU -from cropability.genomics.fastcall3 import FastCall3Runner, FastCall3Config, FastCall3RunResult +from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner, FastCall3RunResult +from cropability.genomics.gwas import GWASEngine, GWASResult +from cropability.genomics.ld import LDCalculator, LDResult from cropability.genomics.pileup import MpileupParser, PileupRecord, PileupSample, PileupSiteSummary -from cropability.genomics.pipeline import VariantPipeline, PipelineConfig, QCThresholds +from cropability.genomics.pipeline import PipelineConfig, QCThresholds, VariantPipeline +from cropability.genomics.variant import SNPResult, VariantCaller __all__ = [ "VariantCaller", diff --git a/cropability/genomics/fastcall3.py b/cropability/genomics/fastcall3.py index 90189de..cfd8a2b 100644 --- a/cropability/genomics/fastcall3.py +++ b/cropability/genomics/fastcall3.py @@ -6,11 +6,11 @@ from __future__ import annotations -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional, Sequence, Union import shutil import subprocess +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path from cropability.utils.logging import get_logger @@ -21,12 +21,12 @@ class FastCall3Config: executable: str = "FastCall3" timeout_seconds: int = 3600 - extra_args: List[str] = field(default_factory=list) + extra_args: list[str] = field(default_factory=list) @dataclass class FastCall3RunResult: - command: List[str] + command: list[str] returncode: int stdout: str stderr: str @@ -40,7 +40,7 @@ def ok(self) -> bool: class FastCall3Runner: """FastCall3 适配执行器。""" - def __init__(self, config: Optional[FastCall3Config] = None) -> None: + def __init__(self, config: FastCall3Config | None = None) -> None: self.config = config or FastCall3Config() def validate_executable(self) -> str: @@ -56,17 +56,17 @@ def validate_executable(self) -> str: def build_command( self, - reference: Union[str, Path], - bam_files: Sequence[Union[str, Path]], - output_vcf: Union[str, Path], - regions: Optional[str] = None, - min_base_quality: Optional[int] = None, - min_mapping_quality: Optional[int] = None, - min_depth: Optional[int] = None, - extra_args: Optional[Sequence[str]] = None, - ) -> List[str]: + reference: str | Path, + bam_files: Sequence[str | Path], + output_vcf: str | Path, + regions: str | None = None, + min_base_quality: int | None = None, + min_mapping_quality: int | None = None, + min_depth: int | None = None, + extra_args: Sequence[str] | None = None, + ) -> list[str]: exe = self.validate_executable() - cmd: List[str] = [exe, "-r", str(reference), "-o", str(output_vcf)] + cmd: list[str] = [exe, "-r", str(reference), "-o", str(output_vcf)] for bam in bam_files: cmd.extend(["-i", str(bam)]) if regions: @@ -84,14 +84,14 @@ def build_command( def run( self, - reference: Union[str, Path], - bam_files: Sequence[Union[str, Path]], - output_vcf: Union[str, Path], - regions: Optional[str] = None, - min_base_quality: Optional[int] = None, - min_mapping_quality: Optional[int] = None, - min_depth: Optional[int] = None, - extra_args: Optional[Sequence[str]] = None, + reference: str | Path, + bam_files: Sequence[str | Path], + output_vcf: str | Path, + regions: str | None = None, + min_base_quality: int | None = None, + min_mapping_quality: int | None = None, + min_depth: int | None = None, + extra_args: Sequence[str] | None = None, dry_run: bool = False, ) -> FastCall3RunResult: output_path = Path(output_vcf) @@ -141,4 +141,3 @@ def run( f"FastCall3 completed but output VCF not found: {output_path}" ) return result - diff --git a/cropability/genomics/pileup.py b/cropability/genomics/pileup.py index 469fdb3..b7e2732 100644 --- a/cropability/genomics/pileup.py +++ b/cropability/genomics/pileup.py @@ -6,9 +6,9 @@ from __future__ import annotations +from collections.abc import Iterator, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, Iterator, List, Optional, Sequence, Tuple, Union from cropability.utils.logging import get_logger @@ -22,7 +22,7 @@ class PileupSample: """单样本位点统计。""" depth: int - base_counts: Dict[str, int] + base_counts: dict[str, int] insertions: int = 0 deletions: int = 0 @@ -38,7 +38,7 @@ class PileupRecord: chrom: str pos: int ref_base: str - samples: Dict[str, PileupSample] = field(default_factory=dict) + samples: dict[str, PileupSample] = field(default_factory=dict) def total_depth(self) -> int: return sum(s.depth for s in self.samples.values()) @@ -52,12 +52,12 @@ class PileupSiteSummary: pos: int ref_base: str depth: int - alt_base: Optional[str] + alt_base: str | None alt_count: int alt_freq: float -def _parse_pileup_bases(bases: str, ref_base: str) -> Tuple[Dict[str, int], int, int]: +def _parse_pileup_bases(bases: str, ref_base: str) -> tuple[dict[str, int], int, int]: counts = {b: 0 for b in _BASES} insertions = 0 deletions = 0 @@ -117,10 +117,10 @@ class MpileupParser: 每个样本占 3 列。 """ - def __init__(self, sample_names: Optional[Sequence[str]] = None) -> None: + def __init__(self, sample_names: Sequence[str] | None = None) -> None: self.sample_names = list(sample_names) if sample_names is not None else None - def parse_line(self, line: str) -> Optional[PileupRecord]: + def parse_line(self, line: str) -> PileupRecord | None: line = line.rstrip("\n") if not line: return None @@ -142,7 +142,7 @@ def parse_line(self, line: str) -> Optional[PileupRecord]: ) names = list(self.sample_names) - samples: Dict[str, PileupSample] = {} + samples: dict[str, PileupSample] = {} for i, name in enumerate(names): depth = int(per_sample[i * 3]) bases = per_sample[i * 3 + 1] @@ -161,7 +161,7 @@ def parse_line(self, line: str) -> Optional[PileupRecord]: samples=samples, ) - def parse_file(self, path: Union[str, Path]) -> Iterator[PileupRecord]: + def parse_file(self, path: str | Path) -> Iterator[PileupRecord]: p = Path(path) with p.open("r", encoding="utf-8") as f: for line in f: @@ -174,8 +174,8 @@ def summarize_sites( records: Iterator[PileupRecord], min_depth: int = 10, min_alt_freq: float = 0.05, - ) -> List[PileupSiteSummary]: - summaries: List[PileupSiteSummary] = [] + ) -> list[PileupSiteSummary]: + summaries: list[PileupSiteSummary] = [] for rec in records: merged_counts = {b: 0 for b in _BASES} depth = 0 @@ -208,4 +208,3 @@ def summarize_sites( logger.info(f"Generated {len(summaries)} pileup site summaries") return summaries - diff --git a/cropability/genomics/pipeline.py b/cropability/genomics/pipeline.py index 26be02e..cb2d166 100644 --- a/cropability/genomics/pipeline.py +++ b/cropability/genomics/pipeline.py @@ -6,12 +6,12 @@ from __future__ import annotations -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional, Sequence, Union import shutil import subprocess import time +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner from cropability.io.bam import AlignmentInputManager @@ -39,7 +39,7 @@ class PipelineConfig: class VariantPipeline: """NGS 变异检测流程。""" - def __init__(self, config: Optional[PipelineConfig] = None) -> None: + def __init__(self, config: PipelineConfig | None = None) -> None: self.config = config or PipelineConfig() self.fastcall3_runner = FastCall3Runner(self.config.fastcall3) @@ -51,18 +51,18 @@ def _resolve_executable(self, exe: str) -> str: def run_mpileup( self, - reference: Union[str, Path], - bam_files: Sequence[Union[str, Path]], - output_path: Union[str, Path], + reference: str | Path, + bam_files: Sequence[str | Path], + output_path: str | Path, qc: QCThresholds, - regions: Optional[str] = None, - extra_args: Optional[Sequence[str]] = None, + regions: str | None = None, + extra_args: Sequence[str] | None = None, dry_run: bool = False, - ) -> Dict[str, object]: + ) -> dict[str, object]: samtools = self._resolve_executable(self.config.samtools_executable) out = Path(output_path) - cmd: List[str] = [ + cmd: list[str] = [ samtools, "mpileup", "-f", @@ -100,14 +100,14 @@ def run_mpileup( def run_fastcall3( self, - reference: Union[str, Path], - bam_files: Sequence[Union[str, Path]], - output_vcf: Union[str, Path], + reference: str | Path, + bam_files: Sequence[str | Path], + output_vcf: str | Path, qc: QCThresholds, - regions: Optional[str] = None, - extra_args: Optional[Sequence[str]] = None, + regions: str | None = None, + extra_args: Sequence[str] | None = None, dry_run: bool = False, - ) -> Dict[str, object]: + ) -> dict[str, object]: result = self.fastcall3_runner.run( reference=reference, bam_files=bam_files, @@ -130,14 +130,14 @@ def run_fastcall3( def run( self, mode: str, - reference: Union[str, Path], - bam_files: Sequence[Union[str, Path]], - output: Union[str, Path], - qc: Optional[QCThresholds] = None, - regions: Optional[str] = None, - mpileup_output: Optional[Union[str, Path]] = None, + reference: str | Path, + bam_files: Sequence[str | Path], + output: str | Path, + qc: QCThresholds | None = None, + regions: str | None = None, + mpileup_output: str | Path | None = None, dry_run: bool = False, - ) -> Dict[str, object]: + ) -> dict[str, object]: mode = mode.lower() if mode not in {"mpileup", "fastcall3", "hybrid"}: raise ValueError("mode must be one of: mpileup, fastcall3, hybrid") @@ -148,7 +148,7 @@ def run( bam_paths = [f.path for f in alignment_files] start = time.time() - report: Dict[str, object] = {"mode": mode, "reference": str(reference)} + report: dict[str, object] = {"mode": mode, "reference": str(reference)} if mode == "mpileup": report["mpileup"] = self.run_mpileup( reference=reference, diff --git a/cropability/io/__init__.py b/cropability/io/__init__.py index 7eaed29..6e939b1 100644 --- a/cropability/io/__init__.py +++ b/cropability/io/__init__.py @@ -2,7 +2,7 @@ from cropability.io.bam import AlignmentFile, AlignmentInputManager from cropability.io.fasta import FastaReader, FastaWriter -from cropability.io.vcf import VCFReader, VCFWriter, VCFRecord +from cropability.io.vcf import VCFReader, VCFRecord, VCFWriter __all__ = [ "AlignmentFile", diff --git a/cropability/io/bam.py b/cropability/io/bam.py index 23f8a6e..d3a2095 100644 --- a/cropability/io/bam.py +++ b/cropability/io/bam.py @@ -6,10 +6,10 @@ from __future__ import annotations +import shutil +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path -from typing import Iterable, List, Optional, Sequence, Tuple, Union -import shutil from cropability.utils.logging import get_logger @@ -24,7 +24,7 @@ class AlignmentFile: sample_name: str fmt: str # bam | cram has_index: bool - index_path: Optional[Path] + index_path: Path | None @property def is_bam(self) -> bool: @@ -44,7 +44,7 @@ def _detect_format(path: Path) -> str: raise ValueError(f"Unsupported alignment format for: {path} (expected .bam/.cram)") -def _resolve_index(path: Path, fmt: str) -> Optional[Path]: +def _resolve_index(path: Path, fmt: str) -> Path | None: if fmt == "bam": candidates = [Path(f"{path}.bai"), path.with_suffix(".bai")] else: @@ -66,7 +66,7 @@ class AlignmentInputManager: - samtools / optional pysam 依赖检查 """ - def __init__(self, paths: Sequence[Union[str, Path]]) -> None: + def __init__(self, paths: Sequence[str | Path]) -> None: if not paths: raise ValueError("At least one alignment file is required") self.paths = [Path(p) for p in paths] @@ -94,8 +94,8 @@ def validate_tools( if require_pysam and not self.check_pysam(): raise RuntimeError("pysam is required but not installed") - def collect(self, require_index: bool = True) -> List[AlignmentFile]: - files: List[AlignmentFile] = [] + def collect(self, require_index: bool = True) -> list[AlignmentFile]: + files: list[AlignmentFile] = [] for p in self.paths: if not p.exists(): raise FileNotFoundError(f"Alignment file not found: {p}") @@ -117,4 +117,3 @@ def collect(self, require_index: bool = True) -> List[AlignmentFile]: ) logger.info(f"Collected {len(files)} alignment files") return files - diff --git a/tests/test_ngs_pipeline.py b/tests/test_ngs_pipeline.py index b0b8c6b..7496198 100644 --- a/tests/test_ngs_pipeline.py +++ b/tests/test_ngs_pipeline.py @@ -1,9 +1,9 @@ """测试 NGS pipeline 新增模块。""" -from pathlib import Path import pytest +from cropability.cli.main import build_parser from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner from cropability.genomics.pileup import MpileupParser from cropability.genomics.pipeline import QCThresholds, VariantPipeline @@ -85,3 +85,24 @@ def test_run_hybrid_dry_run(self, monkeypatch, tmp_path): assert report["mode"] == "hybrid" assert "mpileup" in report assert "fastcall3" in report + + +class TestCliExtensions: + def test_new_subcommands_exist(self): + parser = build_parser() + ns = parser.parse_args( + [ + "call-variants", + "-r", + "ref.fa", + "-b", + "a.bam", + "-o", + "out.vcf", + "--mode", + "hybrid", + "--dry-run", + ] + ) + assert ns.command == "call-variants" + assert ns.mode == "hybrid" From 9421770698e5b15f33c52cc2434a5510d98c5c80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 07:03:02 +0000 Subject: [PATCH 3/3] feat: implement native in-process mpileup and FastCall3-style calling Agent-Logs-Url: https://github.com/SirLearning/CropAbility/sessions/f1b48c50-007c-477d-b90d-91f26dff9f60 Co-authored-by: SirLearning <74081090+SirLearning@users.noreply.github.com> --- README.md | 7 +- cropability/cli/main.py | 12 +- cropability/genomics/fastcall3.py | 260 +++++++++++++++++++++--------- cropability/genomics/pileup.py | 185 +++++++++++++++++++-- cropability/genomics/pipeline.py | 137 +++++++++------- tests/test_ngs_pipeline.py | 54 +++++-- 6 files changed, 494 insertions(+), 161 deletions(-) diff --git a/README.md b/README.md index 2bfdaec..10c700a 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,10 @@ cropability snp -i samples.fa --min-af 0.05 --min-depth 10 # 计算 LD 矩阵 cropability ld --n-samples 500 --n-snps 1000 -# 运行 mpileup(真实 BAM/CRAM 输入) +# 运行原生 mpileup(真实 BAM/CRAM 输入) cropability pileup -r ref.fa -b sample1.bam sample2.bam -o cohort.mpileup -# 运行变异检测流程(默认 hybrid: mpileup + FastCall3) +# 运行变异检测流程(默认 hybrid: 原生 mpileup + 原生 FastCall3 逻辑) cropability call-variants -r ref.fa -b sample1.bam sample2.bam -o cohort.vcf --mode hybrid # 导出 TorchScript 模型(供 Java 调用) @@ -87,7 +87,8 @@ cropability export --model add --output model.pt cropability export --model embedding --output embed.pt ``` -> NGS 流程依赖外部工具:`samtools`(必需),`FastCall3`(`fastcall3`/`hybrid` 模式必需)。 +> NGS 流程默认在 CropAbility 内部执行,不依赖外部 `samtools/FastCall3` 命令。 +> 运行时需要 `pysam`(`pip install "cropability[io]"`)。可选 Rust 后端可用于性能加速。 ### Python API diff --git a/cropability/cli/main.py b/cropability/cli/main.py index 3f7caa2..9758ff3 100644 --- a/cropability/cli/main.py +++ b/cropability/cli/main.py @@ -9,7 +9,7 @@ ld — 计算连锁不平衡矩阵 gwas — 全基因组关联分析 align — 批量序列比对 - pileup — 运行 samtools mpileup + pileup — 运行原生 mpileup(CropAbility 内置) call-variants — 运行 NGS 变异检测流程 export — 导出 TorchScript 模型 benchmark — GPU 性能基准测试 @@ -202,7 +202,7 @@ def cmd_ld(args: argparse.Namespace) -> int: def cmd_pileup(args: argparse.Namespace) -> int: - """运行 samtools mpileup 并输出 pileup 文本。""" + """运行原生 mpileup 并输出位点汇总文本。""" from cropability.genomics.pipeline import QCThresholds, VariantPipeline qc = QCThresholds( @@ -221,13 +221,13 @@ def cmd_pileup(args: argparse.Namespace) -> int: regions=args.region, dry_run=args.dry_run, ) - print("mpileup completed") + print("native pileup completed") print(f" output: {report['mpileup']['output']}") return 0 def cmd_call_variants(args: argparse.Namespace) -> int: - """运行变异检测流程(mpileup / fastcall3 / hybrid)。""" + """运行原生变异检测流程(mpileup / fastcall3 / hybrid)。""" from cropability.genomics.pipeline import QCThresholds, VariantPipeline qc = QCThresholds( @@ -300,10 +300,10 @@ def build_parser() -> argparse.ArgumentParser: ld.add_argument("--n-snps", type=int, default=500) # pileup - pileup = sub.add_parser("pileup", help="运行 samtools mpileup") + pileup = sub.add_parser("pileup", help="运行原生 mpileup") pileup.add_argument("-r", "--reference", required=True, help="参考基因组 FASTA") pileup.add_argument("-b", "--bam", required=True, nargs="+", help="输入 BAM/CRAM 文件列表") - pileup.add_argument("-o", "--output", required=True, help="输出 mpileup 文本路径") + pileup.add_argument("-o", "--output", required=True, help="输出位点汇总路径(TSV)") pileup.add_argument("--region", default=None, help="区域过滤,如 chr1:1-100000") pileup.add_argument("--min-depth", type=int, default=10, help="最小深度阈值") pileup.add_argument("--min-baseq", type=int, default=20, help="最小碱基质量") diff --git a/cropability/genomics/fastcall3.py b/cropability/genomics/fastcall3.py index cfd8a2b..360de5d 100644 --- a/cropability/genomics/fastcall3.py +++ b/cropability/genomics/fastcall3.py @@ -1,17 +1,19 @@ """ -FastCall3 外部调用适配层 +FastCall3 native adapter ======================== -将 FastCall3 作为外部程序纳入 CropAbility 流水线,负责参数映射与执行。 +Implements in-process variant calling in CropAbility without invoking external +FastCall3 or samtools binaries, with an optional Rust acceleration hook. """ from __future__ import annotations -import shutil -import subprocess +import time from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path +from cropability.genomics.pileup import NativePileupEngine, PileupRecord +from cropability.io.vcf import VCFRecord, VCFWriter from cropability.utils.logging import get_logger logger = get_logger(__name__) @@ -19,8 +21,8 @@ @dataclass class FastCall3Config: - executable: str = "FastCall3" timeout_seconds: int = 3600 + prefer_rust_backend: bool = True extra_args: list[str] = field(default_factory=list) @@ -31,56 +33,62 @@ class FastCall3RunResult: stdout: str stderr: str output_vcf: Path + backend: str = "python" + n_records: int = 0 + elapsed_seconds: float = 0.0 @property def ok(self) -> bool: return self.returncode == 0 and self.output_vcf.exists() +def _choose_genotype(ref_count: int, alt_count: int, depth: int, min_depth: int) -> str: + if depth < min_depth: + return "./." + if depth == 0: + return "./." + af = alt_count / depth + if af >= 0.8: + return "1/1" + if af >= 0.2: + return "0/1" + return "0/0" + + class FastCall3Runner: - """FastCall3 适配执行器。""" + """Native FastCall3-style variant caller executed fully in CropAbility.""" def __init__(self, config: FastCall3Config | None = None) -> None: self.config = config or FastCall3Config() + self.pileup_engine = NativePileupEngine(prefer_rust_backend=self.config.prefer_rust_backend) - def validate_executable(self) -> str: - resolved = shutil.which(self.config.executable) - if resolved is None: - p = Path(self.config.executable) - if not p.exists(): - raise RuntimeError( - f"FastCall3 executable not found: {self.config.executable}" - ) - resolved = str(p) - return resolved - - def build_command( + def _run_rust_backend( self, reference: str | Path, bam_files: Sequence[str | Path], output_vcf: str | Path, - regions: str | None = None, - min_base_quality: int | None = None, - min_mapping_quality: int | None = None, - min_depth: int | None = None, - extra_args: Sequence[str] | None = None, - ) -> list[str]: - exe = self.validate_executable() - cmd: list[str] = [exe, "-r", str(reference), "-o", str(output_vcf)] - for bam in bam_files: - cmd.extend(["-i", str(bam)]) - if regions: - cmd.extend(["-R", regions]) - if min_base_quality is not None: - cmd.extend(["--min-base-qual", str(min_base_quality)]) - if min_mapping_quality is not None: - cmd.extend(["--min-mapq", str(min_mapping_quality)]) - if min_depth is not None: - cmd.extend(["--min-depth", str(min_depth)]) - cmd.extend(self.config.extra_args) - if extra_args: - cmd.extend([str(x) for x in extra_args]) - return cmd + regions: str | None, + min_base_quality: int, + min_mapping_quality: int, + min_depth: int, + min_alt_freq: float, + ) -> FastCall3RunResult | None: + if not self.config.prefer_rust_backend: + return None + backend = self.pileup_engine.try_get_rust_backend() + if backend is None: + return None + logger.info("Using optional Rust backend for native FastCall3 call") + return backend.run_fastcall3( + reference=str(reference), + bam_files=[str(x) for x in bam_files], + output_vcf=str(output_vcf), + regions=regions, + min_base_quality=min_base_quality, + min_mapping_quality=min_mapping_quality, + min_depth=min_depth, + min_alt_freq=min_alt_freq, + ) def run( self, @@ -91,53 +99,159 @@ def run( min_base_quality: int | None = None, min_mapping_quality: int | None = None, min_depth: int | None = None, + min_alt_freq: float = 0.05, extra_args: Sequence[str] | None = None, dry_run: bool = False, ) -> FastCall3RunResult: output_path = Path(output_vcf) - cmd = self.build_command( - reference=reference, - bam_files=bam_files, - output_vcf=output_path, - regions=regions, - min_base_quality=min_base_quality, - min_mapping_quality=min_mapping_quality, - min_depth=min_depth, - extra_args=extra_args, - ) - logger.info("Running FastCall3: %s", " ".join(cmd)) + mbq = 20 if min_base_quality is None else min_base_quality + mmq = 20 if min_mapping_quality is None else min_mapping_quality + mdp = 10 if min_depth is None else min_depth + + command = [ + "cropability-fastcall3-native", + "--reference", + str(reference), + "--output", + str(output_path), + "--min-base-quality", + str(mbq), + "--min-mapping-quality", + str(mmq), + "--min-depth", + str(mdp), + "--min-alt-freq", + str(min_alt_freq), + ] + for bam in bam_files: + command.extend(["--bam", str(bam)]) + if regions: + command.extend(["--regions", regions]) + if self.config.extra_args: + command.extend(self.config.extra_args) + if extra_args: + command.extend([str(x) for x in extra_args]) if dry_run: return FastCall3RunResult( - command=cmd, + command=command, returncode=0, - stdout="", + stdout="dry-run", stderr="", output_vcf=output_path, + backend="dry-run", + n_records=0, + elapsed_seconds=0.0, ) - proc = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=self.config.timeout_seconds, - check=False, + rust_result = self._run_rust_backend( + reference=reference, + bam_files=bam_files, + output_vcf=output_vcf, + regions=regions, + min_base_quality=mbq, + min_mapping_quality=mmq, + min_depth=mdp, + min_alt_freq=min_alt_freq, + ) + if rust_result is not None: + return rust_result + + start = time.time() + pileup_records = self.pileup_engine.generate_records( + reference=reference, + bam_files=bam_files, + regions=regions, + min_base_quality=mbq, + min_mapping_quality=mmq, ) + output_path.parent.mkdir(parents=True, exist_ok=True) + n_records = 0 + sample_names = [Path(x).stem for x in bam_files] + meta = [ + '##INFO=', + '##INFO=', + '##INFO=', + '##FORMAT=', + '##FORMAT=', + '##FORMAT=', + ] + + with VCFWriter(output_path, sample_names=sample_names) as writer: + writer.write_header(source="CropAbility.NativeFastCall3", extra_meta=meta) + for rec in pileup_records: + out = self._call_record( + rec=rec, + sample_names=sample_names, + min_depth=mdp, + min_alt_freq=min_alt_freq, + ) + if out is None: + continue + writer.write_record(out) + n_records += 1 - result = FastCall3RunResult( - command=cmd, - returncode=proc.returncode, - stdout=proc.stdout, - stderr=proc.stderr, + elapsed = time.time() - start + return FastCall3RunResult( + command=command, + returncode=0, + stdout=f"native fastcall3 completed with {n_records} records", + stderr="", output_vcf=output_path, + backend="python", + n_records=n_records, + elapsed_seconds=elapsed, + ) + + def _call_record( + self, + rec: PileupRecord, + sample_names: Sequence[str], + min_depth: int, + min_alt_freq: float, + ) -> VCFRecord | None: + total_depth = 0 + merged: dict[str, int] = {k: 0 for k in ("A", "C", "G", "T", "N")} + for sample in rec.samples.values(): + total_depth += sample.depth + for base, count in sample.base_counts.items(): + merged[base] = merged.get(base, 0) + count + + if total_depth < min_depth: + return None + + ref = rec.ref_base.upper() + candidates = {b: c for b, c in merged.items() if b in {"A", "C", "G", "T"} and b != ref} + if not candidates: + return None + alt, alt_count = max(candidates.items(), key=lambda kv: kv[1]) + alt_freq = alt_count / max(1, total_depth) + if alt_freq < min_alt_freq: + return None + + sample_values: list[str] = [] + for sample_name in sample_names: + s = rec.samples[sample_name] + depth = s.depth + ref_count = s.base_counts.get(ref, 0) + sample_alt_count = s.base_counts.get(alt, 0) + gt = _choose_genotype(ref_count, sample_alt_count, depth, min_depth) + sample_values.append(f"{gt}:{depth}:{ref_count},{sample_alt_count}") + + return VCFRecord( + chrom=rec.chrom, + pos=rec.pos, + id=".", + ref=ref, + alt=[alt], + qual=None, + filter=[], + info={ + "DP": str(total_depth), + "AF": f"{alt_freq:.6f}", + "AC": str(alt_count), + }, + format=["GT", "DP", "AD"], + samples=sample_values, ) - if proc.returncode != 0: - raise RuntimeError( - f"FastCall3 failed with exit code {proc.returncode}: {proc.stderr.strip()}" - ) - if not output_path.exists(): - raise RuntimeError( - f"FastCall3 completed but output VCF not found: {output_path}" - ) - return result diff --git a/cropability/genomics/pileup.py b/cropability/genomics/pileup.py index b7e2732..f3d35f7 100644 --- a/cropability/genomics/pileup.py +++ b/cropability/genomics/pileup.py @@ -1,7 +1,8 @@ """ -mpileup 解析与标准化 -=================== -提供对 samtools mpileup 文本输出的解析、计数统计与位点摘要能力。 +mpileup parsing and native generation +==================================== +Provides parser helpers for text mpileup and in-process pileup generation from +alignment files using pysam, with an optional Rust acceleration hook. """ from __future__ import annotations @@ -19,7 +20,7 @@ @dataclass class PileupSample: - """单样本位点统计。""" + """Single-sample per-site pileup summary.""" depth: int base_counts: dict[str, int] @@ -33,7 +34,7 @@ def alt_count(self) -> int: @dataclass class PileupRecord: - """单个位点记录。""" + """Single genomic site pileup summary across one or more samples.""" chrom: str pos: int @@ -46,7 +47,7 @@ def total_depth(self) -> int: @dataclass class PileupSiteSummary: - """跨样本位点汇总。""" + """Merged cross-sample site summary for threshold-based filtering.""" chrom: str pos: int @@ -90,7 +91,7 @@ def _parse_pileup_bases(bases: str, ref_base: str) -> tuple[dict[str, int], int, deletions += 1 i += 1 continue - if c in ".,": # 与参考一致 + if c in ".,": # matches reference if ref_u in counts: counts[ref_u] += 1 else: @@ -110,11 +111,11 @@ def _parse_pileup_bases(bases: str, ref_base: str) -> tuple[dict[str, int], int, class MpileupParser: """ - mpileup 文本解析器。 + Parser for text-based mpileup output lines. - mpileup 格式: + Input format: CHROM POS REF [DP BASES QUAL]... - 每个样本占 3 列。 + where each sample occupies three columns. """ def __init__(self, sample_names: Sequence[str] | None = None) -> None: @@ -187,7 +188,11 @@ def summarize_sites( if depth < min_depth: continue ref = rec.ref_base - alt_candidates = {b: c for b, c in merged_counts.items() if b in {"A", "C", "G", "T"} and b != ref} + alt_candidates = { + b: c + for b, c in merged_counts.items() + if b in {"A", "C", "G", "T"} and b != ref + } if not alt_candidates: continue alt_base, alt_count = max(alt_candidates.items(), key=lambda kv: kv[1]) @@ -206,5 +211,161 @@ def summarize_sites( ) ) - logger.info(f"Generated {len(summaries)} pileup site summaries") + logger.info("Generated %d pileup site summaries", len(summaries)) return summaries + + +class NativePileupEngine: + """In-process pileup engine replacing external `samtools mpileup` calls.""" + + def __init__(self, prefer_rust_backend: bool = True) -> None: + self.prefer_rust_backend = prefer_rust_backend + + def try_get_rust_backend(self): + if not self.prefer_rust_backend: + return None + try: + # Optional extension point. This module is intentionally optional. + from cropability.native import ngs_core # type: ignore + + return ngs_core + except Exception: + return None + + def _require_pysam(self): + try: + import pysam # noqa: PLC0415 + + return pysam + except Exception as e: # pragma: no cover - import path dependent + raise RuntimeError( + "pysam is required for native pileup. Install optional dependency: cropability[io]" + ) from e + + def _parse_region(self, region: str | None) -> tuple[str | None, int | None, int | None]: + if not region: + return None, None, None + if ":" not in region: + return region, None, None + chrom, span = region.split(":", 1) + if "-" not in span: + return chrom, None, None + start_s, end_s = span.split("-", 1) + start = max(0, int(start_s.replace(",", "")) - 1) + end = int(end_s.replace(",", "")) + return chrom, start, end + + def _count_sample_column( + self, + column, + ref_base: str, + min_base_quality: int, + min_mapping_quality: int, + ) -> PileupSample: + counts = {b: 0 for b in _BASES} + insertions = 0 + deletions = 0 + depth = 0 + ref_base_u = ref_base.upper() + for pr in column.pileups: + aln = pr.alignment + if aln.mapping_quality < min_mapping_quality: + continue + if pr.is_refskip: + continue + if pr.is_del: + deletions += 1 + continue + qpos = pr.query_position + if qpos is None: + continue + if qpos >= len(aln.query_qualities): + continue + if aln.query_qualities[qpos] < min_base_quality: + continue + depth += 1 + if pr.indel > 0: + insertions += 1 + elif pr.indel < 0: + deletions += 1 + + base = aln.query_sequence[qpos].upper() + if base not in counts: + base = "N" + if base == ref_base_u: + counts[ref_base_u] += 1 + else: + counts[base] += 1 + return PileupSample(depth=depth, base_counts=counts, insertions=insertions, deletions=deletions) + + def generate_records( + self, + reference: str | Path, + bam_files: Sequence[str | Path], + regions: str | None = None, + min_base_quality: int = 20, + min_mapping_quality: int = 20, + ) -> Iterator[PileupRecord]: + rust_backend = self.try_get_rust_backend() + if rust_backend is not None and hasattr(rust_backend, "generate_pileup_records"): + logger.info("Using optional Rust backend for pileup generation") + yield from rust_backend.generate_pileup_records( + reference=str(reference), + bam_files=[str(x) for x in bam_files], + regions=regions, + min_base_quality=min_base_quality, + min_mapping_quality=min_mapping_quality, + ) + return + + pysam = self._require_pysam() + sample_names = [Path(x).stem for x in bam_files] + chrom_filter, start, end = self._parse_region(regions) + ref = pysam.FastaFile(str(reference)) + bam_handles = [pysam.AlignmentFile(str(p), "rc" if str(p).endswith(".cram") else "rb") for p in bam_files] + try: + site_table: dict[tuple[str, int], dict[str, PileupSample]] = {} + ref_table: dict[tuple[str, int], str] = {} + for sample_name, bam in zip(sample_names, bam_handles): + pileup_iter = bam.pileup( + contig=chrom_filter, + start=start, + stop=end, + truncate=chrom_filter is not None, + min_base_quality=0, # handled by custom thresholds + stepper="all", + ) + for col in pileup_iter: + chrom = col.reference_name + pos1 = col.pos + 1 + key = (chrom, pos1) + if key not in ref_table: + ref_base = ref.fetch(chrom, col.pos, col.pos + 1).upper() + ref_table[key] = ref_base if ref_base else "N" + sample_pileup = self._count_sample_column( + column=col, + ref_base=ref_table[key], + min_base_quality=min_base_quality, + min_mapping_quality=min_mapping_quality, + ) + site_table.setdefault(key, {})[sample_name] = sample_pileup + + for (chrom, pos1), sample_data in sorted(site_table.items()): + for sample_name in sample_names: + if sample_name not in sample_data: + sample_data[sample_name] = PileupSample( + depth=0, + base_counts={b: 0 for b in _BASES}, + insertions=0, + deletions=0, + ) + yield PileupRecord( + chrom=chrom, + pos=pos1, + ref_base=ref_table[(chrom, pos1)], + samples=sample_data, + ) + finally: + ref.close() + for h in bam_handles: + h.close() diff --git a/cropability/genomics/pipeline.py b/cropability/genomics/pipeline.py index cb2d166..4b431f7 100644 --- a/cropability/genomics/pipeline.py +++ b/cropability/genomics/pipeline.py @@ -1,19 +1,19 @@ """ -NGS 变异检测流程编排 -==================== -统一管理 mpileup / FastCall3 / hybrid 三种调用模式。 +NGS variant detection pipeline orchestration +=========================================== +Native in-process pipeline for mpileup-like summaries and FastCall3-style VCF +calling without shelling out to external binaries. """ from __future__ import annotations -import shutil -import subprocess import time from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner +from cropability.genomics.pileup import MpileupParser, NativePileupEngine from cropability.io.bam import AlignmentInputManager from cropability.utils.logging import get_logger @@ -30,73 +30,87 @@ class QCThresholds: @dataclass class PipelineConfig: - samtools_executable: str = "samtools" - bcftools_executable: str = "bcftools" fastcall3: FastCall3Config = field(default_factory=FastCall3Config) + prefer_rust_backend: bool = True timeout_seconds: int = 3600 class VariantPipeline: - """NGS 变异检测流程。""" + """Native NGS variant pipeline.""" def __init__(self, config: PipelineConfig | None = None) -> None: self.config = config or PipelineConfig() self.fastcall3_runner = FastCall3Runner(self.config.fastcall3) + self.pileup_engine = NativePileupEngine(prefer_rust_backend=self.config.prefer_rust_backend) - def _resolve_executable(self, exe: str) -> str: - resolved = shutil.which(exe) - if resolved is None: - raise RuntimeError(f"Executable not found in PATH: {exe}") - return resolved - - def run_mpileup( + def _write_pileup_summary( self, reference: str | Path, bam_files: Sequence[str | Path], output_path: str | Path, qc: QCThresholds, regions: str | None = None, - extra_args: Sequence[str] | None = None, - dry_run: bool = False, ) -> dict[str, object]: - samtools = self._resolve_executable(self.config.samtools_executable) out = Path(output_path) - - cmd: list[str] = [ - samtools, - "mpileup", - "-f", - str(reference), - "-q", - str(qc.min_mapping_quality), - "-Q", - str(qc.min_base_quality), - ] - if regions: - cmd.extend(["-r", regions]) - if extra_args: - cmd.extend([str(x) for x in extra_args]) - cmd.extend([str(b) for b in bam_files]) - - logger.info("Running mpileup: %s", " ".join(cmd)) - if dry_run: - return {"command": cmd, "output": str(out), "returncode": 0} - + records = self.pileup_engine.generate_records( + reference=reference, + bam_files=bam_files, + regions=regions, + min_base_quality=qc.min_base_quality, + min_mapping_quality=qc.min_mapping_quality, + ) out.parent.mkdir(parents=True, exist_ok=True) + n_sites = 0 + parser = MpileupParser() with out.open("w", encoding="utf-8") as f: - proc = subprocess.run( - cmd, - stdout=f, - stderr=subprocess.PIPE, - text=True, - timeout=self.config.timeout_seconds, - check=False, - ) - if proc.returncode != 0: - raise RuntimeError( - f"samtools mpileup failed with exit code {proc.returncode}: {proc.stderr.strip()}" + f.write("#CHROM\tPOS\tREF\tDP\tALT\tAC\tAF\n") + summaries = parser.summarize_sites( + records=records, + min_depth=qc.min_depth, + min_alt_freq=qc.min_alt_freq, ) - return {"command": cmd, "output": str(out), "returncode": proc.returncode} + for s in summaries: + f.write( + f"{s.chrom}\t{s.pos}\t{s.ref_base}\t{s.depth}\t" + f"{s.alt_base or '.'}\t{s.alt_count}\t{s.alt_freq:.6f}\n" + ) + n_sites += 1 + return { + "engine": "native", + "output": str(out), + "returncode": 0, + "n_sites": n_sites, + } + + def run_mpileup( + self, + reference: str | Path, + bam_files: Sequence[str | Path], + output_path: str | Path, + qc: QCThresholds, + regions: str | None = None, + dry_run: bool = False, + ) -> dict[str, object]: + if dry_run: + return { + "engine": "native", + "command": [ + "cropability-native-pileup", + "--reference", + str(reference), + "--output", + str(output_path), + ], + "output": str(output_path), + "returncode": 0, + } + return self._write_pileup_summary( + reference=reference, + bam_files=bam_files, + output_path=output_path, + qc=qc, + regions=regions, + ) def run_fastcall3( self, @@ -105,7 +119,6 @@ def run_fastcall3( output_vcf: str | Path, qc: QCThresholds, regions: str | None = None, - extra_args: Sequence[str] | None = None, dry_run: bool = False, ) -> dict[str, object]: result = self.fastcall3_runner.run( @@ -116,15 +129,18 @@ def run_fastcall3( min_base_quality=qc.min_base_quality, min_mapping_quality=qc.min_mapping_quality, min_depth=qc.min_depth, - extra_args=extra_args, + min_alt_freq=qc.min_alt_freq, dry_run=dry_run, ) return { + "engine": result.backend, "command": result.command, "output": str(result.output_vcf), "returncode": result.returncode, "stdout": result.stdout, "stderr": result.stderr, + "n_records": result.n_records, + "elapsed_seconds": result.elapsed_seconds, } def run( @@ -143,12 +159,13 @@ def run( raise ValueError("mode must be one of: mpileup, fastcall3, hybrid") qc = qc or QCThresholds() - AlignmentInputManager(bam_files).validate_tools(require_samtools=(mode in {"mpileup", "hybrid"})) - alignment_files = AlignmentInputManager(bam_files).collect(require_index=True) + input_manager = AlignmentInputManager(bam_files) + input_manager.validate_tools(require_samtools=False, require_pysam=not dry_run) + alignment_files = input_manager.collect(require_index=True) bam_paths = [f.path for f in alignment_files] start = time.time() - report: dict[str, object] = {"mode": mode, "reference": str(reference)} + report: dict[str, object] = {"mode": mode, "reference": str(reference), "engine": "native"} if mode == "mpileup": report["mpileup"] = self.run_mpileup( reference=reference, @@ -168,7 +185,11 @@ def run( dry_run=dry_run, ) else: - mpileup_out = Path(mpileup_output) if mpileup_output is not None else Path(f"{output}.mpileup") + mpileup_out = ( + Path(mpileup_output) + if mpileup_output is not None + else Path(f"{output}.pileup.summary.tsv") + ) report["mpileup"] = self.run_mpileup( reference=reference, bam_files=bam_paths, @@ -185,6 +206,6 @@ def run( regions=regions, dry_run=dry_run, ) - report["elapsed_seconds"] = time.time() - start return report + diff --git a/tests/test_ngs_pipeline.py b/tests/test_ngs_pipeline.py index 7496198..a84d7b1 100644 --- a/tests/test_ngs_pipeline.py +++ b/tests/test_ngs_pipeline.py @@ -5,9 +5,10 @@ from cropability.cli.main import build_parser from cropability.genomics.fastcall3 import FastCall3Config, FastCall3Runner -from cropability.genomics.pileup import MpileupParser +from cropability.genomics.pileup import MpileupParser, PileupRecord, PileupSample from cropability.genomics.pipeline import QCThresholds, VariantPipeline from cropability.io.bam import AlignmentInputManager +from cropability.io.vcf import VCFReader class TestAlignmentInputManager: @@ -45,10 +46,9 @@ def test_parse_line_and_summary(self): class TestFastCall3Runner: - def test_build_and_dry_run(self, monkeypatch, tmp_path): - monkeypatch.setattr("shutil.which", lambda x: f"/usr/bin/{x}") + def test_build_and_dry_run(self, tmp_path): out = tmp_path / "out.vcf" - runner = FastCall3Runner(FastCall3Config(executable="FastCall3")) + runner = FastCall3Runner(FastCall3Config()) result = runner.run( reference="ref.fa", bam_files=["a.bam", "b.bam"], @@ -60,14 +60,48 @@ def test_build_and_dry_run(self, monkeypatch, tmp_path): dry_run=True, ) assert result.returncode == 0 - assert "-r" in result.command - assert "-o" in result.command - assert "--min-base-qual" in result.command + assert result.backend == "dry-run" + assert "--reference" in result.command + assert "--output" in result.command + + def test_native_run_writes_vcf(self, monkeypatch, tmp_path): + out = tmp_path / "out.vcf" + runner = FastCall3Runner(FastCall3Config(prefer_rust_backend=False)) + + fake_records = [ + PileupRecord( + chrom="chr1", + pos=42, + ref_base="A", + samples={ + "a": PileupSample(depth=10, base_counts={"A": 7, "C": 3, "G": 0, "T": 0, "N": 0}), + "b": PileupSample(depth=8, base_counts={"A": 3, "C": 5, "G": 0, "T": 0, "N": 0}), + }, + ) + ] + monkeypatch.setattr(runner.pileup_engine, "generate_records", lambda **_: iter(fake_records)) + + res = runner.run( + reference="ref.fa", + bam_files=["a.bam", "b.bam"], + output_vcf=out, + min_depth=1, + min_alt_freq=0.1, + dry_run=False, + ) + assert res.ok + assert res.n_records == 1 + + records = list(VCFReader(out)) + assert len(records) == 1 + assert records[0].chrom == "chr1" + assert records[0].pos == 42 + assert records[0].ref == "A" + assert records[0].alt == ["C"] class TestVariantPipeline: - def test_run_hybrid_dry_run(self, monkeypatch, tmp_path): - monkeypatch.setattr("shutil.which", lambda x: f"/usr/bin/{x}") + def test_run_hybrid_dry_run(self, tmp_path): bam = tmp_path / "cohort.bam" bai = tmp_path / "cohort.bam.bai" bam.write_bytes(b"") @@ -85,6 +119,8 @@ def test_run_hybrid_dry_run(self, monkeypatch, tmp_path): assert report["mode"] == "hybrid" assert "mpileup" in report assert "fastcall3" in report + assert report["engine"] == "native" + assert report["fastcall3"]["engine"] == "dry-run" class TestCliExtensions: