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

1073 lines
44 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()