1073 lines
44 KiB
Python
1073 lines
44 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""
|
|||
|
|
RNA-Seq QC阶段:对原始测序数据进行质量控制
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
- 原始FASTQ文件(双端)
|
|||
|
|
- 样本配置文件(group_sample.yaml)
|
|||
|
|
|
|||
|
|
输出:
|
|||
|
|
- FastQC报告
|
|||
|
|
- Cutadapt处理后的clean reads
|
|||
|
|
- 质控报告和manifest
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import yaml
|
|||
|
|
import shutil
|
|||
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|||
|
|
import hashlib
|
|||
|
|
import os
|
|||
|
|
|
|||
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|||
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|||
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|||
|
|
|
|||
|
|
from gfa_core.rnaseq_context import apply_rnaseq_context, build_rnaseq_context, resolve_rnaseq_project_context
|
|||
|
|
from apps.cli.rnaseq_env import prepend_path, rnaseq_bin
|
|||
|
|
|
|||
|
|
|
|||
|
|
RNASEQ_BIN = str(rnaseq_bin())
|
|||
|
|
prepend_path(RNASEQ_BIN)
|
|||
|
|
|
|||
|
|
def validate_input_files(input_dir: Path, config: Dict[str, Any]) -> Dict[str, Dict[str, Path]]:
|
|||
|
|
"""
|
|||
|
|
严格验证输入文件:检查R1+R2配对、文件存在、文件大小>0
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
验证通过的样本字典 {sample_name: {"r1": Path, "r2": Path}}
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
SystemExit: 验证失败时退出并返回错误信息
|
|||
|
|
"""
|
|||
|
|
samples = config.get("samples", {})
|
|||
|
|
if not samples:
|
|||
|
|
print("❌ ERROR: No samples defined in configuration file", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
validated_samples = {}
|
|||
|
|
errors = []
|
|||
|
|
|
|||
|
|
for sample_name, paths in samples.items():
|
|||
|
|
# 解析路径
|
|||
|
|
if isinstance(paths, list):
|
|||
|
|
if len(paths) < 2:
|
|||
|
|
errors.append(f"Sample '{sample_name}': insufficient paths (need 2, got {len(paths)})")
|
|||
|
|
continue
|
|||
|
|
r1_path, r2_path = paths[0], paths[1]
|
|||
|
|
elif isinstance(paths, dict):
|
|||
|
|
r1_path = paths.get("r1") or paths.get("read1")
|
|||
|
|
r2_path = paths.get("r2") or paths.get("read2")
|
|||
|
|
if not r1_path or not r2_path:
|
|||
|
|
errors.append(f"Sample '{sample_name}': missing r1/r2 paths")
|
|||
|
|
continue
|
|||
|
|
else:
|
|||
|
|
errors.append(f"Sample '{sample_name}': unknown format {type(paths)}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 解析完整路径
|
|||
|
|
r1 = Path(r1_path) if Path(r1_path).is_absolute() else input_dir / r1_path
|
|||
|
|
r2 = Path(r2_path) if Path(r2_path).is_absolute() else input_dir / r2_path
|
|||
|
|
|
|||
|
|
# 验证文件存在
|
|||
|
|
if not r1.exists():
|
|||
|
|
errors.append(f"Sample '{sample_name}': R1 file not found: {r1}")
|
|||
|
|
continue
|
|||
|
|
if not r2.exists():
|
|||
|
|
errors.append(f"Sample '{sample_name}': R2 file not found: {r2}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 验证文件大小>0
|
|||
|
|
r1_size = r1.stat().st_size
|
|||
|
|
r2_size = r2.stat().st_size
|
|||
|
|
if r1_size == 0:
|
|||
|
|
errors.append(f"Sample '{sample_name}': R1 file is empty: {r1}")
|
|||
|
|
continue
|
|||
|
|
if r2_size == 0:
|
|||
|
|
errors.append(f"Sample '{sample_name}': R2 file is empty: {r2}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 验证R1和R2大小合理(差距不超过10倍)
|
|||
|
|
if r1_size > 0 and r2_size > 0:
|
|||
|
|
ratio = max(r1_size, r2_size) / min(r1_size, r2_size)
|
|||
|
|
if ratio > 10:
|
|||
|
|
errors.append(f"Sample '{sample_name}': R1/R2 file size mismatch (ratio: {ratio:.1f})")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
validated_samples[sample_name] = {"r1": r1, "r2": r2, "r1_size": r1_size, "r2_size": r2_size}
|
|||
|
|
|
|||
|
|
# 报告错误并退出
|
|||
|
|
if errors:
|
|||
|
|
print(f"\n❌ ERROR: Input validation failed for {len(errors)} sample(s):\n", file=sys.stderr)
|
|||
|
|
for err in errors:
|
|||
|
|
print(f" - {err}", file=sys.stderr)
|
|||
|
|
print(f"\nTotal samples in config: {len(samples)}", file=sys.stderr)
|
|||
|
|
print(f"Valid samples: {len(validated_samples)}", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
print(f"✅ Input validation passed: {len(validated_samples)} sample(s) ready")
|
|||
|
|
for name, info in validated_samples.items():
|
|||
|
|
print(f" - {name}: R1={info['r1_size']/1024/1024:.1f}MB, R2={info['r2_size']/1024/1024:.1f}MB")
|
|||
|
|
|
|||
|
|
return validated_samples
|
|||
|
|
|
|||
|
|
|
|||
|
|
def compute_file_hash(filepath: Path) -> str:
|
|||
|
|
"""计算文件的SHA256哈希值"""
|
|||
|
|
sha256_hash = hashlib.sha256()
|
|||
|
|
try:
|
|||
|
|
with open(filepath, "rb") as f:
|
|||
|
|
for byte_block in iter(lambda: f.read(4096), b""):
|
|||
|
|
sha256_hash.update(byte_block)
|
|||
|
|
return sha256_hash.hexdigest()
|
|||
|
|
except Exception:
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fastp_version(fastp_bin: Optional[Path] = None) -> str:
|
|||
|
|
if not fastp_bin:
|
|||
|
|
return "not_configured"
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
[str(fastp_bin), "--version"],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
check=False,
|
|||
|
|
timeout=5,
|
|||
|
|
)
|
|||
|
|
output = result.stdout.strip() or result.stderr.strip()
|
|||
|
|
return output.split("\n")[0] if output else "unknown"
|
|||
|
|
except Exception:
|
|||
|
|
return "not_found"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_fastp_bin(fastp_bin_arg: str = "") -> Optional[Path]:
|
|||
|
|
candidates = []
|
|||
|
|
if fastp_bin_arg:
|
|||
|
|
candidates.append(Path(fastp_bin_arg))
|
|||
|
|
env_fastp = os.environ.get("FASTP_BIN", "").strip()
|
|||
|
|
if env_fastp:
|
|||
|
|
candidates.append(Path(env_fastp))
|
|||
|
|
path_fastp = shutil.which("fastp")
|
|||
|
|
if path_fastp:
|
|||
|
|
candidates.append(Path(path_fastp))
|
|||
|
|
for candidate in candidates:
|
|||
|
|
if candidate.exists() and candidate.is_file() and os.access(candidate, os.X_OK):
|
|||
|
|
return candidate
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_tool_versions(fastp_bin: Optional[Path] = None) -> Dict[str, str]:
|
|||
|
|
"""获取所需工具版本信息"""
|
|||
|
|
versions = {}
|
|||
|
|
|
|||
|
|
# fastqc
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
[f"{RNASEQ_BIN}/fastqc", "--version"],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
check=False,
|
|||
|
|
timeout=5
|
|||
|
|
)
|
|||
|
|
output = result.stdout.strip() or result.stderr.strip()
|
|||
|
|
versions["fastqc"] = output.split('\n')[0] if output else "unknown"
|
|||
|
|
except Exception:
|
|||
|
|
versions["fastqc"] = "not_found"
|
|||
|
|
|
|||
|
|
# cutadapt
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
[f"{RNASEQ_BIN}/cutadapt", "--version"],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
check=False,
|
|||
|
|
timeout=5
|
|||
|
|
)
|
|||
|
|
output = result.stdout.strip() or result.stderr.strip()
|
|||
|
|
versions["cutadapt"] = output.split('\n')[0] if output else "unknown"
|
|||
|
|
except Exception:
|
|||
|
|
versions["cutadapt"] = "not_found"
|
|||
|
|
|
|||
|
|
# MultiQC
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(
|
|||
|
|
[f"{RNASEQ_BIN}/multiqc", "--version"],
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
check=False,
|
|||
|
|
timeout=10
|
|||
|
|
)
|
|||
|
|
output = result.stdout.strip() or result.stderr.strip()
|
|||
|
|
versions["multiqc"] = output.split('\n')[0] if output else "unknown"
|
|||
|
|
except Exception:
|
|||
|
|
versions["multiqc"] = "not_found"
|
|||
|
|
|
|||
|
|
versions["fastp"] = fastp_version(fastp_bin)
|
|||
|
|
|
|||
|
|
return versions
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_samples_config(config_file: Path) -> Dict[str, Any]:
|
|||
|
|
"""加载样本配置文件(支持合并格式:samples + groups + compare)"""
|
|||
|
|
with open(config_file, 'r') as f:
|
|||
|
|
config = yaml.safe_load(f)
|
|||
|
|
|
|||
|
|
# 确保有 samples 字段
|
|||
|
|
if "samples" not in config:
|
|||
|
|
raise ValueError(f"Config file {config_file} must contain 'samples' field")
|
|||
|
|
|
|||
|
|
return config
|
|||
|
|
|
|||
|
|
|
|||
|
|
RNASEQ_BIN = str(rnaseq_bin())
|
|||
|
|
prepend_path(RNASEQ_BIN)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_thread_plan(cpu: int, sample_parallel_jobs: int = 1, threads_per_sample: Optional[int] = None) -> Dict[str, int]:
|
|||
|
|
"""Resolve conservative RNA-seq sample/thread settings from CLI args."""
|
|||
|
|
total_cpu = max(1, int(cpu or 1))
|
|||
|
|
jobs = max(1, int(sample_parallel_jobs or 1))
|
|||
|
|
if threads_per_sample is None or int(threads_per_sample) < 1:
|
|||
|
|
threads = max(1, total_cpu // jobs)
|
|||
|
|
else:
|
|||
|
|
threads = max(1, int(threads_per_sample))
|
|||
|
|
if jobs * threads > total_cpu:
|
|||
|
|
threads = max(1, total_cpu // jobs)
|
|||
|
|
return {"cpu": total_cpu, "sample_parallel_jobs": jobs, "threads_per_sample": threads}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fastqc_output_base(fastq_path: Path) -> str:
|
|||
|
|
"""Return FastQC output basename without _fastqc suffix for .fastq/.fq(.gz)."""
|
|||
|
|
name = fastq_path.name
|
|||
|
|
for suffix in (".fastq.gz", ".fq.gz", ".fastq", ".fq"):
|
|||
|
|
if name.lower().endswith(suffix):
|
|||
|
|
return name[:-len(suffix)]
|
|||
|
|
return fastq_path.stem
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sample_qc_outputs_complete(sample_name: str, file_info: Dict[str, Path], analysis_dir: Path, qc_tool: str = "fastp") -> bool:
|
|||
|
|
r1 = file_info["r1"]
|
|||
|
|
r2 = file_info["r2"]
|
|||
|
|
if qc_tool == "fastp":
|
|||
|
|
required = [
|
|||
|
|
analysis_dir / f"{sample_name}_1.clean.fastq.gz",
|
|||
|
|
analysis_dir / f"{sample_name}_2.clean.fastq.gz",
|
|||
|
|
analysis_dir / f"{sample_name}_fastp.html",
|
|||
|
|
analysis_dir / f"{sample_name}_fastp.json",
|
|||
|
|
analysis_dir / f"{sample_name}_fastp.log",
|
|||
|
|
]
|
|||
|
|
else:
|
|||
|
|
required = [
|
|||
|
|
analysis_dir / f"{fastqc_output_base(r1)}_fastqc.html",
|
|||
|
|
analysis_dir / f"{fastqc_output_base(r1)}_fastqc.zip",
|
|||
|
|
analysis_dir / f"{fastqc_output_base(r2)}_fastqc.html",
|
|||
|
|
analysis_dir / f"{fastqc_output_base(r2)}_fastqc.zip",
|
|||
|
|
analysis_dir / f"{sample_name}_1.clean.fastq.gz",
|
|||
|
|
analysis_dir / f"{sample_name}_2.clean.fastq.gz",
|
|||
|
|
analysis_dir / f"{sample_name}_cutadapt.log",
|
|||
|
|
]
|
|||
|
|
return all(path.exists() and path.stat().st_size > 0 for path in required)
|
|||
|
|
|
|||
|
|
def run_fastqc(fastq_files: List[Path], output_dir: Path, cpu: int = 4) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
运行FastQC质控
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict with success status and timing info
|
|||
|
|
"""
|
|||
|
|
results = {"success": True, "files": {}, "total_time_sec": 0}
|
|||
|
|
fastqc_path = f"{RNASEQ_BIN}/fastqc"
|
|||
|
|
|
|||
|
|
for fq in fastq_files:
|
|||
|
|
cmd = [fastqc_path, "-o", str(output_dir), "-t", str(cpu), str(fq)]
|
|||
|
|
file_size_mb = fq.stat().st_size / 1024 / 1024
|
|||
|
|
|
|||
|
|
print(f" [FastQC] Starting: {fq.name} ({file_size_mb:.1f} MB)...", flush=True)
|
|||
|
|
start_ts = time.time()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|||
|
|
elapsed = time.time() - start_ts
|
|||
|
|
results["files"][fq.name] = {"success": True, "time_sec": elapsed}
|
|||
|
|
results["total_time_sec"] += elapsed
|
|||
|
|
print(f" [FastQC] Completed: {fq.name} in {elapsed:.1f}s", flush=True)
|
|||
|
|
except subprocess.CalledProcessError as e:
|
|||
|
|
elapsed = time.time() - start_ts
|
|||
|
|
results["files"][fq.name] = {"success": False, "time_sec": elapsed, "error": str(e)}
|
|||
|
|
results["success"] = False
|
|||
|
|
print(f" [FastQC] FAILED: {fq.name} after {elapsed:.1f}s: {e.stderr}", file=sys.stderr)
|
|||
|
|
raise RuntimeError(f"FastQC failed for {fq.name}: {e.stderr}")
|
|||
|
|
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_cutadapt(
|
|||
|
|
r1: Path,
|
|||
|
|
r2: Path,
|
|||
|
|
out_r1: Path,
|
|||
|
|
out_r2: Path,
|
|||
|
|
log_file: Path,
|
|||
|
|
adapter_r1: str = "AGATCGGAAGAGC",
|
|||
|
|
adapter_r2: str = "AGATCGGAAGAGC",
|
|||
|
|
quality_cutoff: int = 20,
|
|||
|
|
min_length: int = 50,
|
|||
|
|
cpu: int = 1
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
运行Cutadapt去除接头
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict with success status, timing info, and stats
|
|||
|
|
"""
|
|||
|
|
cmd = [
|
|||
|
|
f"{RNASEQ_BIN}/cutadapt",
|
|||
|
|
"-a", adapter_r1,
|
|||
|
|
"-A", adapter_r2,
|
|||
|
|
"-o", str(out_r1),
|
|||
|
|
"-p", str(out_r2),
|
|||
|
|
"-q", str(quality_cutoff),
|
|||
|
|
"-m", str(min_length),
|
|||
|
|
"-j", str(max(1, int(cpu or 1))),
|
|||
|
|
str(r1),
|
|||
|
|
str(r2)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
r1_size_mb = r1.stat().st_size / 1024 / 1024
|
|||
|
|
r2_size_mb = r2.stat().st_size / 1024 / 1024
|
|||
|
|
|
|||
|
|
print(f" [Cutadapt] Starting: {r1.name} + {r2.name} ({r1_size_mb+r2_size_mb:.1f} MB total)...", flush=True)
|
|||
|
|
start_ts = time.time()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|||
|
|
elapsed = time.time() - start_ts
|
|||
|
|
|
|||
|
|
with open(log_file, 'w') as f:
|
|||
|
|
f.write(result.stdout)
|
|||
|
|
|
|||
|
|
# 验证输出文件
|
|||
|
|
if not out_r1.exists() or out_r1.stat().st_size == 0:
|
|||
|
|
raise RuntimeError(f"Cutadapt output R1 is empty or missing: {out_r1}")
|
|||
|
|
if not out_r2.exists() or out_r2.stat().st_size == 0:
|
|||
|
|
raise RuntimeError(f"Cutadapt output R2 is empty or missing: {out_r2}")
|
|||
|
|
|
|||
|
|
out_size_mb = (out_r1.stat().st_size + out_r2.stat().st_size) / 1024 / 1024
|
|||
|
|
print(f" [Cutadapt] Completed in {elapsed:.1f}s (output: {out_size_mb:.1f} MB)", flush=True)
|
|||
|
|
|
|||
|
|
return {"success": True, "time_sec": elapsed, "log": result.stdout}
|
|||
|
|
|
|||
|
|
except subprocess.CalledProcessError as e:
|
|||
|
|
elapsed = time.time() - start_ts
|
|||
|
|
with open(log_file, 'w') as f:
|
|||
|
|
f.write(e.stdout + "\n" + e.stderr)
|
|||
|
|
print(f" [Cutadapt] FAILED after {elapsed:.1f}s: {e.stderr}", file=sys.stderr)
|
|||
|
|
raise RuntimeError(f"Cutadapt failed: {e.stderr}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
elapsed = time.time() - start_ts
|
|||
|
|
print(f" [Cutadapt] FAILED after {elapsed:.1f}s: {str(e)}", file=sys.stderr)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_fastp(
|
|||
|
|
fastp_bin: Path,
|
|||
|
|
r1: Path,
|
|||
|
|
r2: Path,
|
|||
|
|
out_r1: Path,
|
|||
|
|
out_r2: Path,
|
|||
|
|
html_report: Path,
|
|||
|
|
json_report: Path,
|
|||
|
|
log_file: Path,
|
|||
|
|
cpu: int = 1,
|
|||
|
|
detect_adapter_for_pe: bool = True,
|
|||
|
|
cut_front: bool = True,
|
|||
|
|
cut_tail: bool = True,
|
|||
|
|
cut_window_size: int = 4,
|
|||
|
|
cut_mean_quality: int = 20,
|
|||
|
|
qualified_quality_phred: int = 20,
|
|||
|
|
unqualified_percent_limit: int = 30,
|
|||
|
|
n_base_limit: int = 5,
|
|||
|
|
length_required: int = 30,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
cmd = [
|
|||
|
|
str(fastp_bin),
|
|||
|
|
"-i", str(r1),
|
|||
|
|
"-I", str(r2),
|
|||
|
|
"-o", str(out_r1),
|
|||
|
|
"-O", str(out_r2),
|
|||
|
|
"--html", str(html_report),
|
|||
|
|
"--json", str(json_report),
|
|||
|
|
"--thread", str(max(1, int(cpu or 1))),
|
|||
|
|
"--cut_window_size", str(cut_window_size),
|
|||
|
|
"--cut_mean_quality", str(cut_mean_quality),
|
|||
|
|
"--qualified_quality_phred", str(qualified_quality_phred),
|
|||
|
|
"--unqualified_percent_limit", str(unqualified_percent_limit),
|
|||
|
|
"--n_base_limit", str(n_base_limit),
|
|||
|
|
"--length_required", str(length_required),
|
|||
|
|
]
|
|||
|
|
if detect_adapter_for_pe:
|
|||
|
|
cmd.append("--detect_adapter_for_pe")
|
|||
|
|
if cut_front:
|
|||
|
|
cmd.append("--cut_front")
|
|||
|
|
if cut_tail:
|
|||
|
|
cmd.append("--cut_tail")
|
|||
|
|
|
|||
|
|
print(f" [fastp] Starting: {r1.name} + {r2.name}...", flush=True)
|
|||
|
|
start_ts = time.time()
|
|||
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|||
|
|
elapsed = time.time() - start_ts
|
|||
|
|
log_file.write_text(
|
|||
|
|
"Command: " + " ".join(cmd) + "\n\n"
|
|||
|
|
+ "--- STDOUT ---\n"
|
|||
|
|
+ result.stdout
|
|||
|
|
+ "\n--- STDERR ---\n"
|
|||
|
|
+ result.stderr,
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
print(f" [fastp] FAILED after {elapsed:.1f}s: {result.stderr}", file=sys.stderr)
|
|||
|
|
raise RuntimeError(f"fastp failed with exit code {result.returncode}: {result.stderr}")
|
|||
|
|
for path in (out_r1, out_r2, html_report, json_report):
|
|||
|
|
if not path.exists() or path.stat().st_size == 0:
|
|||
|
|
raise RuntimeError(f"fastp output is missing or empty: {path}")
|
|||
|
|
print(f" [fastp] Completed in {elapsed:.1f}s", flush=True)
|
|||
|
|
return {"success": True, "time_sec": elapsed, "cmd": cmd, "log": str(log_file)}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_multiqc(search_paths: Sequence[Path], output_dir: Path, report_name: str) -> Optional[Path]:
|
|||
|
|
"""Run MultiQC over selected QC outputs and return the report path."""
|
|||
|
|
multiqc = Path(f"{RNASEQ_BIN}/multiqc")
|
|||
|
|
existing = [str(path) for path in search_paths if path.exists()]
|
|||
|
|
if not multiqc.exists() or not existing:
|
|||
|
|
return None
|
|||
|
|
cmd = [str(multiqc), *existing, "-o", str(output_dir), "-n", report_name, "-f"]
|
|||
|
|
try:
|
|||
|
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|||
|
|
report = output_dir / report_name
|
|||
|
|
return report if report.exists() else None
|
|||
|
|
except subprocess.CalledProcessError as e:
|
|||
|
|
print(f"MultiQC failed: {e.stderr}", file=sys.stderr)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_sample_stats(sample_dir: Path, sample_name: str) -> Dict[str, Any]:
|
|||
|
|
"""获取样本统计信息"""
|
|||
|
|
import re
|
|||
|
|
|
|||
|
|
def first_count_after_colon(text: str) -> Optional[int]:
|
|||
|
|
value = text.split(":", 1)[1] if ":" in text else text
|
|||
|
|
match = re.search(r'([\d,]+)', value)
|
|||
|
|
if match:
|
|||
|
|
return int(match.group(1).replace(',', ''))
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
stats = {
|
|||
|
|
"raw_reads": 0,
|
|||
|
|
"clean_reads": 0,
|
|||
|
|
"adapter_removed": 0,
|
|||
|
|
"quality_filtered": 0
|
|||
|
|
}
|
|||
|
|
fastp_json = sample_dir / f"{sample_name}_fastp.json"
|
|||
|
|
if fastp_json.exists():
|
|||
|
|
try:
|
|||
|
|
payload = json.loads(fastp_json.read_text(encoding="utf-8"))
|
|||
|
|
before = payload.get("summary", {}).get("before_filtering", {})
|
|||
|
|
after = payload.get("summary", {}).get("after_filtering", {})
|
|||
|
|
stats["raw_reads"] = int(before.get("total_reads", 0))
|
|||
|
|
stats["clean_reads"] = int(after.get("total_reads", 0))
|
|||
|
|
stats["fastp_q20_rate_before"] = before.get("q20_rate")
|
|||
|
|
stats["fastp_q30_rate_before"] = before.get("q30_rate")
|
|||
|
|
stats["fastp_q20_rate_after"] = after.get("q20_rate")
|
|||
|
|
stats["fastp_q30_rate_after"] = after.get("q30_rate")
|
|||
|
|
stats["read1_mean_length"] = after.get("read1_mean_length")
|
|||
|
|
stats["read2_mean_length"] = after.get("read2_mean_length")
|
|||
|
|
stats["gc_content_after"] = after.get("gc_content")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Warning: Could not read fastp JSON for {sample_name}: {e}")
|
|||
|
|
|
|||
|
|
# 读取FastQC数据(从zip文件中提取)
|
|||
|
|
fastqc_zip = sample_dir / f"{sample_name}_1_fastqc.zip"
|
|||
|
|
if fastqc_zip.exists():
|
|||
|
|
try:
|
|||
|
|
import zipfile
|
|||
|
|
with zipfile.ZipFile(fastqc_zip, 'r') as zf:
|
|||
|
|
# 找到 fastqc_data.txt 文件
|
|||
|
|
data_file = None
|
|||
|
|
for name in zf.namelist():
|
|||
|
|
if name.endswith('fastqc_data.txt'):
|
|||
|
|
data_file = name
|
|||
|
|
break
|
|||
|
|
if data_file:
|
|||
|
|
with zf.open(data_file) as f:
|
|||
|
|
for line in f:
|
|||
|
|
line = line.decode('utf-8')
|
|||
|
|
if line.startswith("Total Sequences"):
|
|||
|
|
stats["raw_reads"] = int(line.split("\t")[1])
|
|||
|
|
elif line.startswith("Sequence length"):
|
|||
|
|
stats["read_length"] = line.split("\t")[1].strip()
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Warning: Could not read FastQC data for {sample_name}: {e}")
|
|||
|
|
|
|||
|
|
# 读取Cutadapt日志
|
|||
|
|
log_file = sample_dir / f"{sample_name}_cutadapt.log"
|
|||
|
|
if log_file.exists():
|
|||
|
|
with open(log_file, 'r') as f:
|
|||
|
|
for line in f:
|
|||
|
|
if "Total reads processed:" in line or "Total read pairs processed:" in line:
|
|||
|
|
count = first_count_after_colon(line)
|
|||
|
|
if count is not None:
|
|||
|
|
stats["raw_reads"] = count
|
|||
|
|
elif "Reads with adapters:" in line or "Read 1 with adapter:" in line or "Read 2 with adapter:" in line:
|
|||
|
|
count = first_count_after_colon(line)
|
|||
|
|
if count is not None:
|
|||
|
|
stats["adapter_removed"] += count
|
|||
|
|
elif "Pairs that were too short:" in line or "Reads that were too short:" in line:
|
|||
|
|
count = first_count_after_colon(line)
|
|||
|
|
if count is not None:
|
|||
|
|
stats["quality_filtered"] += count
|
|||
|
|
elif "Reads written (passing filters):" in line or "Pairs written (passing filters):" in line:
|
|||
|
|
count = first_count_after_colon(line)
|
|||
|
|
if count is not None:
|
|||
|
|
stats["clean_reads"] = count
|
|||
|
|
|
|||
|
|
return stats
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_report(
|
|||
|
|
output_dir: Path,
|
|||
|
|
sample_stats: Dict[str, Dict[str, Any]],
|
|||
|
|
versions: Dict[str, str],
|
|||
|
|
start_time: datetime,
|
|||
|
|
end_time: datetime,
|
|||
|
|
prefix: str = "",
|
|||
|
|
qc_tool: str = "fastp",
|
|||
|
|
) -> Path:
|
|||
|
|
"""生成质控报告"""
|
|||
|
|
report_file = output_dir / f"{prefix}.rnaseq_qc_report.md" if prefix else output_dir / "rnaseq_qc_report.md"
|
|||
|
|
|
|||
|
|
elapsed = (end_time - start_time).total_seconds()
|
|||
|
|
|
|||
|
|
content = f"""# RNA-Seq Quality Control Report
|
|||
|
|
|
|||
|
|
## Summary
|
|||
|
|
|
|||
|
|
- **Analysis Date**: {end_time.strftime("%Y-%m-%d %H:%M:%S")}
|
|||
|
|
- **Total Samples**: {len(sample_stats)}
|
|||
|
|
- **Total Runtime**: {elapsed:.1f} seconds
|
|||
|
|
- **QC Tool**: {qc_tool}
|
|||
|
|
|
|||
|
|
## Tool Versions
|
|||
|
|
|
|||
|
|
"""
|
|||
|
|
for tool, version in versions.items():
|
|||
|
|
content += f"- **{tool}**: {version}\n"
|
|||
|
|
|
|||
|
|
content += "\n## Sample Statistics\n\n"
|
|||
|
|
content += "| Sample | Raw Reads | Clean Reads | Adapter Removed |\n"
|
|||
|
|
content += "|--------|-----------|-------------|-----------------|\n"
|
|||
|
|
|
|||
|
|
for sample, stats in sample_stats.items():
|
|||
|
|
content += f"| {sample} | {stats.get('raw_reads', 'N/A')} | {stats.get('clean_reads', 'N/A')} | {stats.get('adapter_removed', 'N/A')} |\n"
|
|||
|
|
|
|||
|
|
with open(report_file, 'w') as f:
|
|||
|
|
f.write(content)
|
|||
|
|
|
|||
|
|
return report_file
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_manifest(
|
|||
|
|
output_dir: Path,
|
|||
|
|
sample_stats: Dict[str, Dict[str, Any]],
|
|||
|
|
versions: Dict[str, str],
|
|||
|
|
start_time: datetime,
|
|||
|
|
end_time: datetime,
|
|||
|
|
cmd: List[str],
|
|||
|
|
input_dir: Path,
|
|||
|
|
workspace_dir: Path,
|
|||
|
|
job_id: str,
|
|||
|
|
run_id: str,
|
|||
|
|
prefix: str = "",
|
|||
|
|
analysis_dir: Path = None, # analysis 目录路径
|
|||
|
|
cpu: int = 4,
|
|||
|
|
sample_parallel_jobs: int = 1,
|
|||
|
|
threads_per_sample: int = 4,
|
|||
|
|
sample_config: Optional[Path] = None,
|
|||
|
|
qc_tool: str = "fastp",
|
|||
|
|
fastp_bin: str = "",
|
|||
|
|
fastp_params: Optional[Dict[str, Any]] = None,
|
|||
|
|
) -> Path:
|
|||
|
|
"""生成manifest文件"""
|
|||
|
|
manifest_file = output_dir / f"{prefix}.rnaseq_qc.manifest.json" if prefix else output_dir / "rnaseq_qc.manifest.json"
|
|||
|
|
|
|||
|
|
total_raw = sum(s.get('raw_reads', 0) for s in sample_stats.values())
|
|||
|
|
total_clean = sum(s.get('clean_reads', 0) for s in sample_stats.values())
|
|||
|
|
|
|||
|
|
manifest = {
|
|||
|
|
"manifest_version": "1.0",
|
|||
|
|
"stage": "rnaseq_qc",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": prefix,
|
|||
|
|
"job_id": job_id,
|
|||
|
|
"run_id": run_id,
|
|||
|
|
"timestamp": end_time.isoformat(),
|
|||
|
|
"start_time": start_time.isoformat(),
|
|||
|
|
"end_time": end_time.isoformat(),
|
|||
|
|
"elapsed_sec": (end_time - start_time).total_seconds(),
|
|||
|
|
"cmd": cmd,
|
|||
|
|
"workspace_root": str(output_dir.parent.parent),
|
|||
|
|
"workspace": str(workspace_dir),
|
|||
|
|
"analysis_dir": str(analysis_dir) if analysis_dir else str(workspace_dir / "analysis"),
|
|||
|
|
"input_dir": str(input_dir),
|
|||
|
|
"metrics": {
|
|||
|
|
"total_samples": len(sample_stats),
|
|||
|
|
"total_raw_reads": total_raw,
|
|||
|
|
"total_clean_reads": total_clean,
|
|||
|
|
"samples": sample_stats
|
|||
|
|
},
|
|||
|
|
"parameters": {
|
|||
|
|
"cpu": cpu,
|
|||
|
|
"sample_parallel_jobs": sample_parallel_jobs,
|
|||
|
|
"threads_per_sample": threads_per_sample,
|
|||
|
|
"qc_tool": qc_tool,
|
|||
|
|
"fastqc_params": f"-t {threads_per_sample}",
|
|||
|
|
"cutadapt_params": f"-q 20 -m 50 -j {threads_per_sample}" if qc_tool == "cutadapt" else "",
|
|||
|
|
"fastp_bin": fastp_bin,
|
|||
|
|
"fastp_params": fastp_params or {},
|
|||
|
|
},
|
|||
|
|
"artifacts": {},
|
|||
|
|
"tools": versions
|
|||
|
|
}
|
|||
|
|
context = build_rnaseq_context(
|
|||
|
|
prefix=prefix,
|
|||
|
|
sample_config=sample_config,
|
|||
|
|
foreground_mode="global",
|
|||
|
|
stage="rnaseq_qc",
|
|||
|
|
)
|
|||
|
|
apply_rnaseq_context(manifest, context)
|
|||
|
|
|
|||
|
|
# 添加artifacts(文件生成在 final/ 目录,使用 prefix 命名)
|
|||
|
|
# 参考 synteny 契约,包含 full_path、size、sha256 等字段
|
|||
|
|
report_name = f"{prefix}.rnaseq_qc_report.md" if prefix else "rnaseq_qc_report.md"
|
|||
|
|
manifest_name = f"{prefix}.rnaseq_qc.manifest.json" if prefix else "rnaseq_qc.manifest.json"
|
|||
|
|
|
|||
|
|
def add_artifact(name: str, filepath: Path, artifact_type: str, **extra):
|
|||
|
|
"""添加 artifact 到 manifest"""
|
|||
|
|
if filepath.exists():
|
|||
|
|
manifest["artifacts"][name] = {
|
|||
|
|
"artifact": name,
|
|||
|
|
"path": f"final/{filepath.name}",
|
|||
|
|
"full_path": str(filepath.absolute()),
|
|||
|
|
"type": artifact_type,
|
|||
|
|
"size": filepath.stat().st_size,
|
|||
|
|
"sha256": compute_file_hash(filepath),
|
|||
|
|
**extra
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# analysis_dir 用于引用质控报告文件(保留在 analysis 目录)
|
|||
|
|
analysis_path = analysis_dir if analysis_dir else workspace_dir / "analysis"
|
|||
|
|
|
|||
|
|
# final_dir 是 output_dir 的父目录下的 final 目录
|
|||
|
|
final_dir = output_dir.parent / "final"
|
|||
|
|
|
|||
|
|
artifact_prefix = prefix if prefix else "rnaseq"
|
|||
|
|
for sample in sample_stats.keys():
|
|||
|
|
# FastQC HTML 报告(复制到 final,前端展示)
|
|||
|
|
html_r1 = final_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_1_fastqc.html"
|
|||
|
|
html_r2 = final_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_2_fastqc.html"
|
|||
|
|
add_artifact(f"qc_report_{sample}_r1", html_r1, "html", format="html")
|
|||
|
|
add_artifact(f"qc_report_{sample}_r2", html_r2, "html", format="html")
|
|||
|
|
|
|||
|
|
# FastQC 数据(zip,保留在 analysis 目录)
|
|||
|
|
zip_r1 = analysis_path / f"{sample}_1_fastqc.zip"
|
|||
|
|
zip_r2 = analysis_path / f"{sample}_2_fastqc.zip"
|
|||
|
|
if zip_r1.exists():
|
|||
|
|
manifest["artifacts"][f"qc_data_{sample}_r1"] = {
|
|||
|
|
"artifact": f"qc_data_{sample}_r1",
|
|||
|
|
"path": f"rnaseq_qc/analysis/{zip_r1.name}",
|
|||
|
|
"full_path": str(zip_r1.absolute()),
|
|||
|
|
"type": "zip",
|
|||
|
|
"size": zip_r1.stat().st_size,
|
|||
|
|
"sha256": compute_file_hash(zip_r1),
|
|||
|
|
"format": "zip"
|
|||
|
|
}
|
|||
|
|
if zip_r2.exists():
|
|||
|
|
manifest["artifacts"][f"qc_data_{sample}_r2"] = {
|
|||
|
|
"artifact": f"qc_data_{sample}_r2",
|
|||
|
|
"path": f"rnaseq_qc/analysis/{zip_r2.name}",
|
|||
|
|
"full_path": str(zip_r2.absolute()),
|
|||
|
|
"type": "zip",
|
|||
|
|
"size": zip_r2.stat().st_size,
|
|||
|
|
"sha256": compute_file_hash(zip_r2),
|
|||
|
|
"format": "zip"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Clean reads(FASTQ)- 大文件,复制到 final 后使用标准前缀命名
|
|||
|
|
clean_r1 = output_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_1.clean.fastq.gz"
|
|||
|
|
clean_r2 = output_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_2.clean.fastq.gz"
|
|||
|
|
add_artifact(f"clean_reads_r1_{sample}", clean_r1, "fastq", format="fastq.gz")
|
|||
|
|
add_artifact(f"clean_reads_r2_{sample}", clean_r2, "fastq", format="fastq.gz")
|
|||
|
|
|
|||
|
|
# Cutadapt 日志
|
|||
|
|
log_file = output_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_cutadapt.log"
|
|||
|
|
add_artifact(f"cutadapt_log_{sample}", log_file, "log", format="txt")
|
|||
|
|
|
|||
|
|
fastp_html = output_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_fastp.html"
|
|||
|
|
fastp_json = output_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_fastp.json"
|
|||
|
|
fastp_log = output_dir / f"{artifact_prefix}.rnaseq_qc.{sample}_fastp.log"
|
|||
|
|
add_artifact(f"fastp_report_{sample}", fastp_html, "html", format="html")
|
|||
|
|
add_artifact(f"fastp_json_{sample}", fastp_json, "json", format="json")
|
|||
|
|
add_artifact(f"fastp_log_{sample}", fastp_log, "log", format="txt")
|
|||
|
|
|
|||
|
|
# 报告、MultiQC 和 manifest
|
|||
|
|
report_file = output_dir / report_name
|
|||
|
|
add_artifact("report", report_file, "markdown", format="md")
|
|||
|
|
multiqc_name = f"{prefix}.rnaseq_qc_multiqc_report.html" if prefix else "rnaseq_qc_multiqc_report.html"
|
|||
|
|
multiqc_file = output_dir / multiqc_name
|
|||
|
|
add_artifact("multiqc_report", multiqc_file, "html", format="html")
|
|||
|
|
multiqc_data = output_dir / multiqc_name.replace(".html", "_data")
|
|||
|
|
if multiqc_data.exists():
|
|||
|
|
manifest["artifacts"]["multiqc_data"] = {
|
|||
|
|
"artifact": "multiqc_data",
|
|||
|
|
"path": f"final/{multiqc_data.name}",
|
|||
|
|
"full_path": str(multiqc_data.absolute()),
|
|||
|
|
"type": "directory",
|
|||
|
|
"format": "multiqc_data"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# manifest 自身(在生成后添加)
|
|||
|
|
manifest["artifacts"]["manifest"] = {
|
|||
|
|
"artifact": "manifest",
|
|||
|
|
"path": f"final/{manifest_name}",
|
|||
|
|
"type": "json",
|
|||
|
|
"format": "json"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with open(manifest_file, 'w') as f:
|
|||
|
|
json.dump(manifest, f, indent=2)
|
|||
|
|
|
|||
|
|
return manifest_file
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description="RNA-Seq Quality Control")
|
|||
|
|
parser.add_argument("-i", "--input", required=True, help="Input directory with FASTQ files")
|
|||
|
|
parser.add_argument("-c", "--config", required=True, help="Sample configuration file (YAML)")
|
|||
|
|
parser.add_argument("-o", "--output", required=True, help="Output directory")
|
|||
|
|
parser.add_argument("-j", "--job-id", default="unknown", help="Job ID")
|
|||
|
|
parser.add_argument("-r", "--run-id", default="unknown", help="Run ID")
|
|||
|
|
parser.add_argument("-t", "--cpu", type=int, default=4, help="Total CPU threads for this stage")
|
|||
|
|
parser.add_argument("--sample-parallel-jobs", type=int, default=1, help="Number of samples to process concurrently")
|
|||
|
|
parser.add_argument("--threads-per-sample", type=int, help="Threads used by each per-sample tool invocation")
|
|||
|
|
parser.add_argument("--resume-existing", action="store_true", help="Skip samples whose FastQC, clean reads, and cutadapt outputs already exist in the output directory")
|
|||
|
|
parser.add_argument("-p", "--prefix", default="", help="Output file prefix")
|
|||
|
|
parser.add_argument("--adapter-r1", default="AGATCGGAAGAGC", help="Adapter sequence for read1")
|
|||
|
|
parser.add_argument("--adapter-r2", default="AGATCGGAAGAGC", help="Adapter sequence for read2")
|
|||
|
|
parser.add_argument("--qc-tool", choices=["cutadapt", "fastp"], default="fastp", help="QC/cleaning backend. Default uses fastp; cutadapt remains available by explicit selection.")
|
|||
|
|
parser.add_argument("--fastp-bin", default="tools/fastp", help="Path to fastp executable. If omitted, FASTP_BIN or PATH is used.")
|
|||
|
|
parser.add_argument("--detect-adapter-for-pe", action=argparse.BooleanOptionalAction, default=True, help="Enable fastp paired-end adapter auto-detection.")
|
|||
|
|
parser.add_argument("--cut-front", action=argparse.BooleanOptionalAction, default=True, help="Enable fastp front quality cutting.")
|
|||
|
|
parser.add_argument("--cut-tail", action=argparse.BooleanOptionalAction, default=True, help="Enable fastp tail quality cutting.")
|
|||
|
|
parser.add_argument("--cut-window-size", type=int, default=4, help="fastp quality cutting window size.")
|
|||
|
|
parser.add_argument("--cut-mean-quality", type=int, default=20, help="fastp mean quality threshold for cutting.")
|
|||
|
|
parser.add_argument("--qualified-quality-phred", type=int, default=20, help="fastp qualified quality phred threshold.")
|
|||
|
|
parser.add_argument("--unqualified-percent-limit", type=int, default=30, help="fastp maximum unqualified base percent.")
|
|||
|
|
parser.add_argument("--n-base-limit", type=int, default=5, help="fastp maximum N bases per read.")
|
|||
|
|
parser.add_argument("--length-required", type=int, default=30, help="fastp minimum output read length.")
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
thread_plan = resolve_thread_plan(args.cpu, args.sample_parallel_jobs, args.threads_per_sample)
|
|||
|
|
threads_per_sample = thread_plan["threads_per_sample"]
|
|||
|
|
|
|||
|
|
start_time = datetime.now()
|
|||
|
|
input_dir = Path(args.input)
|
|||
|
|
output_dir = Path(args.output) # rnaseq_qc 工作目录
|
|||
|
|
config_file = Path(args.config)
|
|||
|
|
context_for_prefix = resolve_rnaseq_project_context(
|
|||
|
|
input_dir=input_dir,
|
|||
|
|
requested_prefix=args.prefix,
|
|||
|
|
sample_config=config_file,
|
|||
|
|
)
|
|||
|
|
resolved_prefix = context_for_prefix.get("output_prefix")
|
|||
|
|
if resolved_prefix and resolved_prefix != args.prefix:
|
|||
|
|
print(
|
|||
|
|
f"[INFO] Using RNA-seq output prefix {resolved_prefix} "
|
|||
|
|
f"instead of requested {args.prefix or '<empty>'} "
|
|||
|
|
f"(source={context_for_prefix.get('source', '')})"
|
|||
|
|
)
|
|||
|
|
args.prefix = resolved_prefix
|
|||
|
|
|
|||
|
|
# 创建工作目录
|
|||
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# 最终结果目录: workspace/<job_id>/final/
|
|||
|
|
# 根据契约,最终产物必须位于 workspace/<job_id>/final/
|
|||
|
|
workspace_dir = output_dir.parent # job_id 目录
|
|||
|
|
final_dir = workspace_dir / "final"
|
|||
|
|
final_dir.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
# 中间文件目录: rnaseq_qc/analysis/
|
|||
|
|
analysis_dir = output_dir / "analysis"
|
|||
|
|
analysis_dir.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
# 加载样本配置并严格验证输入文件
|
|||
|
|
config = load_samples_config(config_file)
|
|||
|
|
|
|||
|
|
# 严格验证输入文件(失败时直接退出)
|
|||
|
|
validated_samples = validate_input_files(input_dir, config)
|
|||
|
|
|
|||
|
|
print(f"\nProcessing {len(validated_samples)} samples...")
|
|||
|
|
|
|||
|
|
fastp_bin = resolve_fastp_bin(args.fastp_bin) if args.qc_tool == "fastp" else None
|
|||
|
|
if args.qc_tool == "fastp" and not fastp_bin:
|
|||
|
|
print("❌ ERROR: --qc-tool fastp requires an executable --fastp-bin, FASTP_BIN, or fastp on PATH", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
fastp_params = {
|
|||
|
|
"detect_adapter_for_pe": bool(args.detect_adapter_for_pe),
|
|||
|
|
"cut_front": bool(args.cut_front),
|
|||
|
|
"cut_tail": bool(args.cut_tail),
|
|||
|
|
"cut_window_size": args.cut_window_size,
|
|||
|
|
"cut_mean_quality": args.cut_mean_quality,
|
|||
|
|
"qualified_quality_phred": args.qualified_quality_phred,
|
|||
|
|
"unqualified_percent_limit": args.unqualified_percent_limit,
|
|||
|
|
"n_base_limit": args.n_base_limit,
|
|||
|
|
"length_required": args.length_required,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 获取工具版本
|
|||
|
|
versions = get_tool_versions(fastp_bin)
|
|||
|
|
print(f"Tool versions: {versions}")
|
|||
|
|
|
|||
|
|
sample_stats = {}
|
|||
|
|
total_samples = len(validated_samples)
|
|||
|
|
|
|||
|
|
def qc_one_sample(sample_name: str, file_info: Dict[str, Path]) -> tuple[str, Optional[Dict[str, Any]], Optional[str]]:
|
|||
|
|
r1 = file_info["r1"]
|
|||
|
|
r2 = file_info["r2"]
|
|||
|
|
print(f"\n[PROGRESS] Processing: {sample_name}", flush=True)
|
|||
|
|
print(f" Input: {r1.name} + {r2.name}", flush=True)
|
|||
|
|
if args.resume_existing and sample_qc_outputs_complete(sample_name, file_info, analysis_dir, args.qc_tool):
|
|||
|
|
stats = get_sample_stats(analysis_dir, sample_name)
|
|||
|
|
stats["resumed_from_existing"] = True
|
|||
|
|
stats["fastqc_time_sec"] = 0
|
|||
|
|
stats["cutadapt_time_sec"] = 0
|
|||
|
|
stats["fastp_time_sec"] = 0
|
|||
|
|
print(f" [RESUME] Skipping completed sample {sample_name}; existing QC outputs are complete", flush=True)
|
|||
|
|
return sample_name, stats, None
|
|||
|
|
|
|||
|
|
out_r1 = analysis_dir / f"{sample_name}_1.clean.fastq.gz"
|
|||
|
|
out_r2 = analysis_dir / f"{sample_name}_2.clean.fastq.gz"
|
|||
|
|
if args.qc_tool == "fastp":
|
|||
|
|
try:
|
|||
|
|
fastp_result = run_fastp(
|
|||
|
|
fastp_bin=fastp_bin,
|
|||
|
|
r1=r1,
|
|||
|
|
r2=r2,
|
|||
|
|
out_r1=out_r1,
|
|||
|
|
out_r2=out_r2,
|
|||
|
|
html_report=analysis_dir / f"{sample_name}_fastp.html",
|
|||
|
|
json_report=analysis_dir / f"{sample_name}_fastp.json",
|
|||
|
|
log_file=analysis_dir / f"{sample_name}_fastp.log",
|
|||
|
|
cpu=threads_per_sample,
|
|||
|
|
**fastp_params,
|
|||
|
|
)
|
|||
|
|
except RuntimeError as e:
|
|||
|
|
return sample_name, None, f"fastp failed for {sample_name}: {e}"
|
|||
|
|
stats = get_sample_stats(analysis_dir, sample_name)
|
|||
|
|
stats["fastqc_time_sec"] = 0
|
|||
|
|
stats["cutadapt_time_sec"] = 0
|
|||
|
|
stats["fastp_time_sec"] = fastp_result.get("time_sec", 0)
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
fastqc_result = run_fastqc([r1, r2], analysis_dir, threads_per_sample)
|
|||
|
|
except RuntimeError as e:
|
|||
|
|
return sample_name, None, f"FastQC failed for {sample_name}: {e}"
|
|||
|
|
log_file = analysis_dir / f"{sample_name}_cutadapt.log"
|
|||
|
|
try:
|
|||
|
|
cutadapt_result = run_cutadapt(
|
|||
|
|
r1, r2, out_r1, out_r2, log_file,
|
|||
|
|
args.adapter_r1, args.adapter_r2,
|
|||
|
|
cpu=threads_per_sample,
|
|||
|
|
)
|
|||
|
|
except RuntimeError as e:
|
|||
|
|
return sample_name, None, f"Cutadapt failed for {sample_name}: {e}"
|
|||
|
|
stats = get_sample_stats(analysis_dir, sample_name)
|
|||
|
|
stats["fastqc_time_sec"] = fastqc_result.get("total_time_sec", 0)
|
|||
|
|
stats["cutadapt_time_sec"] = cutadapt_result.get("time_sec", 0)
|
|||
|
|
stats["fastp_time_sec"] = 0
|
|||
|
|
|
|||
|
|
print(f" ✓ Sample {sample_name} completed", flush=True)
|
|||
|
|
return sample_name, stats, None
|
|||
|
|
|
|||
|
|
print(
|
|||
|
|
f"Thread plan: sample_parallel_jobs={thread_plan['sample_parallel_jobs']}, "
|
|||
|
|
f"threads_per_sample={threads_per_sample}, total_cpu={thread_plan['cpu']}"
|
|||
|
|
)
|
|||
|
|
with ThreadPoolExecutor(max_workers=max(1, int(thread_plan["sample_parallel_jobs"]))) as executor:
|
|||
|
|
future_map = {
|
|||
|
|
executor.submit(qc_one_sample, sample_name, file_info): sample_name
|
|||
|
|
for sample_name, file_info in validated_samples.items()
|
|||
|
|
}
|
|||
|
|
for future in as_completed(future_map):
|
|||
|
|
sample_name = future_map[future]
|
|||
|
|
try:
|
|||
|
|
completed_name, stats, error = future.result()
|
|||
|
|
except Exception as exc:
|
|||
|
|
print(f"\n❌ ERROR: QC failed for {sample_name}: {exc}", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
if error:
|
|||
|
|
print(f"\n❌ ERROR: {error}", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
if stats is not None:
|
|||
|
|
sample_stats[completed_name] = stats
|
|||
|
|
|
|||
|
|
# 复制结果到 final 目录,使用标准命名格式
|
|||
|
|
safe_prefix = args.prefix if args.prefix else "rnaseq"
|
|||
|
|
|
|||
|
|
print(f"\n[PROGRESS] Copying results to final directory...")
|
|||
|
|
copied_files = 0
|
|||
|
|
expected_files_per_sample = 5 # cutadapt: 2 clean + 2 FastQC HTML + 1 log; fastp: 2 clean + HTML + JSON + log
|
|||
|
|
|
|||
|
|
for sample_name, file_info in validated_samples.items():
|
|||
|
|
sample_copied = 0
|
|||
|
|
r1_name = file_info["r1"].stem.replace('.fastq', '').replace('.fq', '') # 原始R1文件名(不含扩展名)
|
|||
|
|
r2_name = file_info["r2"].stem.replace('.fastq', '').replace('.fq', '') # 原始R2文件名(不含扩展名)
|
|||
|
|
|
|||
|
|
# 复制 clean reads 到 final(下游分析需要)
|
|||
|
|
for suffix in ["_1.clean.fastq.gz", "_2.clean.fastq.gz"]:
|
|||
|
|
src = analysis_dir / f"{sample_name}{suffix}"
|
|||
|
|
dst = final_dir / f"{safe_prefix}.rnaseq_qc.{sample_name}{suffix}"
|
|||
|
|
if src.exists() and src.stat().st_size > 0:
|
|||
|
|
shutil.copy2(src, dst)
|
|||
|
|
copied_files += 1
|
|||
|
|
sample_copied += 1
|
|||
|
|
else:
|
|||
|
|
print(f" ⚠ Missing/empty: {src.name}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
if args.qc_tool == "fastp":
|
|||
|
|
for src_name, dst_suffix in [
|
|||
|
|
(f"{sample_name}_fastp.html", f"{sample_name}_fastp.html"),
|
|||
|
|
(f"{sample_name}_fastp.json", f"{sample_name}_fastp.json"),
|
|||
|
|
(f"{sample_name}_fastp.log", f"{sample_name}_fastp.log"),
|
|||
|
|
]:
|
|||
|
|
src = analysis_dir / src_name
|
|||
|
|
dst = final_dir / f"{safe_prefix}.rnaseq_qc.{dst_suffix}"
|
|||
|
|
if src.exists() and src.stat().st_size > 0:
|
|||
|
|
shutil.copy2(src, dst)
|
|||
|
|
copied_files += 1
|
|||
|
|
sample_copied += 1
|
|||
|
|
else:
|
|||
|
|
print(f" ⚠ Missing/empty fastp output: {src_name}", file=sys.stderr)
|
|||
|
|
else:
|
|||
|
|
# 复制 FastQC HTML 报告到 final(前端展示需要)
|
|||
|
|
# FastQC使用原始FASTQ文件名生成报告
|
|||
|
|
fastqc_patterns = [
|
|||
|
|
(f"{r1_name}_fastqc.html", f"{sample_name}_1_fastqc.html"),
|
|||
|
|
(f"{r2_name}_fastqc.html", f"{sample_name}_2_fastqc.html")
|
|||
|
|
]
|
|||
|
|
for src_pattern, dst_suffix in fastqc_patterns:
|
|||
|
|
src = analysis_dir / src_pattern
|
|||
|
|
dst = final_dir / f"{safe_prefix}.rnaseq_qc.{dst_suffix}"
|
|||
|
|
if src.exists() and src.stat().st_size > 0:
|
|||
|
|
shutil.copy2(src, dst)
|
|||
|
|
copied_files += 1
|
|||
|
|
sample_copied += 1
|
|||
|
|
else:
|
|||
|
|
print(f" ⚠ Missing/empty FastQC HTML: {src_pattern}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
# 复制 cutadapt 日志到 final(小文件,包含统计信息)
|
|||
|
|
src = analysis_dir / f"{sample_name}_cutadapt.log"
|
|||
|
|
dst = final_dir / f"{safe_prefix}.rnaseq_qc.{sample_name}_cutadapt.log"
|
|||
|
|
if src.exists() and src.stat().st_size > 0:
|
|||
|
|
shutil.copy2(src, dst)
|
|||
|
|
copied_files += 1
|
|||
|
|
sample_copied += 1
|
|||
|
|
else:
|
|||
|
|
print(f" ⚠ Missing/empty: {src.name}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
print(f" {sample_name}: {sample_copied}/{expected_files_per_sample} files copied")
|
|||
|
|
|
|||
|
|
# 验证输出完整性
|
|||
|
|
total_expected = len(validated_samples) * expected_files_per_sample
|
|||
|
|
if copied_files < total_expected:
|
|||
|
|
print(f"\n❌ ERROR: Output validation failed", file=sys.stderr)
|
|||
|
|
print(f" Expected: {total_expected} files", file=sys.stderr)
|
|||
|
|
print(f" Copied: {copied_files} files", file=sys.stderr)
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
print(f"\n✅ All {copied_files} output files validated and copied")
|
|||
|
|
|
|||
|
|
# 生成 MultiQC 报告
|
|||
|
|
multiqc_name = f"{args.prefix}.rnaseq_qc_multiqc_report.html" if args.prefix else "rnaseq_qc_multiqc_report.html"
|
|||
|
|
multiqc_report = run_multiqc([analysis_dir, final_dir], final_dir, multiqc_name)
|
|||
|
|
if multiqc_report:
|
|||
|
|
print(f"MultiQC report: {multiqc_report}")
|
|||
|
|
|
|||
|
|
# 生成报告
|
|||
|
|
end_time = datetime.now()
|
|||
|
|
generate_report(final_dir, sample_stats, versions, start_time, end_time, args.prefix, qc_tool=args.qc_tool)
|
|||
|
|
|
|||
|
|
# 生成manifest
|
|||
|
|
cmd = ["python", __file__, "-i", str(input_dir), "-c", str(config_file),
|
|||
|
|
"-o", str(output_dir), "-j", args.job_id, "-r", args.run_id, "-p", args.prefix,
|
|||
|
|
"-t", str(thread_plan["cpu"]),
|
|||
|
|
"--sample-parallel-jobs", str(thread_plan["sample_parallel_jobs"]),
|
|||
|
|
"--threads-per-sample", str(thread_plan["threads_per_sample"]),
|
|||
|
|
"--qc-tool", args.qc_tool]
|
|||
|
|
if args.qc_tool == "fastp":
|
|||
|
|
cmd.extend(["--fastp-bin", str(fastp_bin)])
|
|||
|
|
for flag, value in {
|
|||
|
|
"--cut-window-size": args.cut_window_size,
|
|||
|
|
"--cut-mean-quality": args.cut_mean_quality,
|
|||
|
|
"--qualified-quality-phred": args.qualified_quality_phred,
|
|||
|
|
"--unqualified-percent-limit": args.unqualified_percent_limit,
|
|||
|
|
"--n-base-limit": args.n_base_limit,
|
|||
|
|
"--length-required": args.length_required,
|
|||
|
|
}.items():
|
|||
|
|
cmd.extend([flag, str(value)])
|
|||
|
|
if args.detect_adapter_for_pe:
|
|||
|
|
cmd.append("--detect-adapter-for-pe")
|
|||
|
|
else:
|
|||
|
|
cmd.append("--no-detect-adapter-for-pe")
|
|||
|
|
if args.cut_front:
|
|||
|
|
cmd.append("--cut-front")
|
|||
|
|
else:
|
|||
|
|
cmd.append("--no-cut-front")
|
|||
|
|
if args.cut_tail:
|
|||
|
|
cmd.append("--cut-tail")
|
|||
|
|
else:
|
|||
|
|
cmd.append("--no-cut-tail")
|
|||
|
|
generate_manifest(
|
|||
|
|
final_dir, sample_stats, versions, start_time, end_time,
|
|||
|
|
cmd, input_dir, output_dir, args.job_id, args.run_id, args.prefix,
|
|||
|
|
analysis_dir=analysis_dir, # 传入 analysis 目录
|
|||
|
|
cpu=args.cpu,
|
|||
|
|
sample_parallel_jobs=thread_plan["sample_parallel_jobs"],
|
|||
|
|
threads_per_sample=threads_per_sample,
|
|||
|
|
sample_config=config_file,
|
|||
|
|
qc_tool=args.qc_tool,
|
|||
|
|
fastp_bin=str(fastp_bin) if fastp_bin else "",
|
|||
|
|
fastp_params=fastp_params if args.qc_tool == "fastp" else {},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
total_elapsed = (end_time - start_time).total_seconds()
|
|||
|
|
print(f"\n{'='*60}")
|
|||
|
|
print(f"✅ RNA-seq QC completed successfully")
|
|||
|
|
print(f" Samples processed: {len(validated_samples)}")
|
|||
|
|
print(f" Total runtime: {total_elapsed:.1f}s ({total_elapsed/60:.1f} min)")
|
|||
|
|
print(f" Results: {output_dir}")
|
|||
|
|
print(f"{'='*60}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|