#!/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 ''} " f"(source={context_for_prefix.get('source', '')})" ) args.prefix = resolved_prefix # 创建工作目录 output_dir.mkdir(parents=True, exist_ok=True) # 最终结果目录: workspace//final/ # 根据契约,最终产物必须位于 workspace//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()