#!/usr/bin/env python3 """ RNA-Seq Alignment阶段:将clean reads比对到参考基因组 输入: - 来自RNA_QC阶段的clean reads - 参考基因组FASTA - HISAT2索引 输出: - 排序后的BAM文件 - 比对报告和manifest """ import argparse import json import subprocess import shutil import sys import time import yaml from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple 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)) import re from gfa_core.rnaseq_context import ( apply_rnaseq_context, build_rnaseq_context, find_rnaseq_hisat2_index, find_rnaseq_reference_file, hisat2_rna_strandness_args, require_rnaseq_library_strandness, RnaseqLibraryStrandnessError, resolve_rnaseq_project_context, ) from cli.rnaseq_env import prepend_path, rnaseq_bin 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 species_token_from_prefix(prefix: str) -> str: """Extract species token from prefixes like Os, Os.WRKY, or Os_WRKY.""" prefix = (prefix or "").strip() if not prefix: return "" return re.split(r"[._]", prefix, maxsplit=1)[0].strip() def find_canonical_genome_fasta(ref_dir: Path, species_token: str) -> Optional[Path]: """Find the canonical genome FASTA: {species}.genome.fasta/fa/fna.""" if not species_token: return None for suffix in ("fasta", "fa", "fna"): candidate = ref_dir / f"{species_token}.genome.{suffix}" if candidate.is_file(): return candidate return None def canonical_genome_index_prefix(ref_dir: Path, species_token: str) -> Optional[Path]: """Return exact {species}.genome HISAT2 index prefix if it exists.""" if not species_token: return None prefix = ref_dir / f"{species_token}.genome" if Path(f"{prefix}.1.ht2").is_file(): return prefix return None def build_hisat2_index(genome_fasta: Path, index_prefix: Path, cpu: int = 4) -> bool: """Build HISAT2 index from the locked genome FASTA.""" index_prefix.parent.mkdir(parents=True, exist_ok=True) cmd = [ f"{RNASEQ_BIN}/hisat2-build", str(genome_fasta), str(index_prefix), "-p", str(cpu), ] try: subprocess.run(cmd, check=True, capture_output=True, text=True) return Path(f"{index_prefix}.1.ht2").exists() except subprocess.CalledProcessError as e: print(f"hisat2-build failed: {e.stderr}", file=sys.stderr) return False def resolve_locked_hisat2_index( requested_index: Path, input_dir: Path, align_dir: Path, prefix: str, sample_config: Optional[str], cpu: int = 4, ) -> Path: """Lock RNA-seq alignment reference to {species}.genome.*.""" sample_dir = Path(sample_config).parent if sample_config else None context = resolve_rnaseq_project_context( input_dir=sample_dir or requested_index.parent, final_dir=input_dir, requested_prefix=prefix, sample_config=sample_config, ) species_token = context.get("species_code") or species_token_from_prefix(context.get("output_prefix") or prefix) reference_id = context.get("reference_id", "") if not species_token or species_token.lower() in {"unknown", "genome", "genome_prepare", "rnaseq"}: raise ValueError( "RNA-seq Align requires a real species code from samples.yaml/config.xlsx, " "for example species.code: Os and rnaseq.outputPrefix: Os" ) ref_dirs = [] if requested_index: ref_dirs.append(requested_index.parent) if sample_dir: ref_dirs.append(sample_dir) ref_dirs.append(input_dir) seen = set() unique_ref_dirs = [] for ref_dir in ref_dirs: resolved = ref_dir.resolve() if resolved not in seen: unique_ref_dirs.append(ref_dir) seen.add(resolved) for ref_dir in unique_ref_dirs: exact_index = find_rnaseq_hisat2_index(ref_dir, species_token, reference_id) if exact_index: print(f"[INFO] Locked RNA-seq genome index: {exact_index}") return exact_index for ref_dir in unique_ref_dirs: genome_fasta = find_rnaseq_reference_file(ref_dir, "genome", species_token, reference_id) if genome_fasta is None: continue build_prefix = align_dir / "index" / f"{species_token}.genome" exact_built = canonical_genome_index_prefix(build_prefix.parent, species_token) if exact_built: print(f"[INFO] Locked RNA-seq genome index: {exact_built}") return exact_built print(f"[INFO] Building locked HISAT2 genome index from {genome_fasta}: {build_prefix}") if not build_hisat2_index(genome_fasta, build_prefix, cpu): raise RuntimeError(f"Failed to build HISAT2 index from {genome_fasta}") return build_prefix searched = ", ".join(str(p) for p in unique_ref_dirs) raise FileNotFoundError( f"RNA-seq Align locked reference lookup failed: resolved species={species_token!r}, " f"reference_id={reference_id!r}; expected {species_token}.genome.fasta " f"(or .fa/.fna) or {species_token}.genome.1.ht2 under: {searched}. " f"Protein/CDS indexes such as {species_token}.pep.*.ht2 or *.cds.*.ht2 are not valid for RNA-seq alignment." ) 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 parse_annotation_attributes(attr_text: str) -> Dict[str, str]: attrs: Dict[str, str] = {} for item in attr_text.split(";"): item = item.strip() if not item: continue if "=" in item: key, value = item.split("=", 1) attrs[key.strip()] = value.strip().strip('"') elif " " in item: key, value = item.split(" ", 1) attrs[key.strip()] = value.strip().strip('"') return attrs def annotation_transcript_id(feature_type: str, attr_text: str) -> Optional[str]: attrs = parse_annotation_attributes(attr_text) feature = feature_type.lower() if feature in {"mrna", "transcript"}: return attrs.get("ID") or attrs.get("transcript_id") or attrs.get("Name") if feature == "exon": parent = attrs.get("Parent") or attrs.get("transcript_id") if parent: return parent.split(",")[0] return attrs.get("ID") return None def extract_hisat2_splice_sites(annotation: Path, output_file: Path) -> Dict[str, Any]: """Extract HISAT2 known splice sites from exon records in GTF/GFF3.""" transcripts: Dict[str, List[Tuple[str, int, int, str]]] = {} with open(annotation, "r", encoding="utf-8", errors="ignore") as handle: for line in handle: if not line.strip() or line.startswith("#"): continue parts = line.rstrip("\n").split("\t") if len(parts) < 9 or parts[2].lower() != "exon": continue transcript_id = annotation_transcript_id(parts[2], parts[8]) if not transcript_id: continue try: start = int(parts[3]) end = int(parts[4]) except ValueError: continue transcripts.setdefault(transcript_id, []).append((parts[0], start, end, parts[6] or ".")) splice_sites = set() for exons in transcripts.values(): exons = sorted(exons, key=lambda row: (row[0], row[1], row[2])) for left, right in zip(exons, exons[1:]): if left[0] != right[0]: continue intron_start = left[2] + 1 intron_end = right[1] - 1 if intron_start <= intron_end: splice_sites.add((left[0], intron_start, intron_end, left[3])) output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, "w", encoding="utf-8") as out: for chrom, start, end, strand in sorted(splice_sites): out.write(f"{chrom}\t{start}\t{end}\t{strand}\n") return { "status": "generated" if splice_sites else "empty", "source": str(annotation), "path": str(output_file), "count": len(splice_sites), } def resolve_known_splice_site( source_mode: str, known_splice_site: Optional[str], final_dir: Path, prefix: str, ) -> Tuple[Optional[Path], Dict[str, Any]]: mode = (source_mode or "auto").strip().lower() if mode not in {"auto", "off", "manual", "annotation"}: mode = "auto" info: Dict[str, Any] = { "mode": mode, "status": "disabled" if mode == "off" else "not_found", "source": "", "path": "", "count": 0, } if mode == "off": return None, info source_path = Path(known_splice_site).expanduser() if known_splice_site else None if not source_path or not source_path.exists(): if mode == "manual": raise FileNotFoundError(f"--known-splice-site does not exist: {known_splice_site}") return None, info suffixes = "".join(source_path.suffixes).lower() if source_path.suffix.lower() in {".ss", ".txt", ".tsv"} and mode != "annotation": info.update({ "status": "provided", "source": str(source_path), "path": str(source_path), "count": sum(1 for line in source_path.read_text(errors="ignore").splitlines() if line.strip() and not line.startswith("#")), }) return source_path, info if source_path.suffix.lower() in {".gtf", ".gff", ".gff3"} or any(token in suffixes for token in (".gtf", ".gff")): out = final_dir / f"{prefix}.hisat2.splice_sites.ss" generated = extract_hisat2_splice_sites(source_path, out) info.update(generated) info["mode"] = mode return (out if out.exists() and out.stat().st_size > 0 else None), info if mode in {"manual", "annotation"}: raise ValueError(f"Unsupported known splice site source format: {source_path}") return None, info def get_tool_versions() -> Dict[str, str]: """获取所需工具版本信息""" versions = {} # hisat2 try: result = subprocess.run( [f"{RNASEQ_BIN}/hisat2", "--version"], capture_output=True, text=True, check=False, timeout=5 ) output = result.stdout.strip() or result.stderr.strip() versions["hisat2"] = output.split('\n')[0] if output else "unknown" except Exception as e: versions["hisat2"] = f"not_found: {e}" # samtools try: result = subprocess.run( [f"{RNASEQ_BIN}/samtools", "--version"], capture_output=True, text=True, check=False, timeout=5 ) output = result.stdout.strip() or result.stderr.strip() versions["samtools"] = output.split('\n')[0] if output else "unknown" except Exception as e: versions["samtools"] = f"not_found: {e}" # 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 as e: versions["multiqc"] = f"not_found: {e}" return versions def run_hisat2_sort( hisat2_idx: Path, r1: Path, r2: Path, sorted_bam: Path, log_out: Path, cpu: int = 4, library_strandness: str = "non_stranded", known_splice_site: Optional[Path] = None, ) -> bool: """Run HISAT2 and stream SAM directly into samtools sort.""" idx_prefix = str(hisat2_idx).rsplit('.', 1)[0] if hisat2_idx.suffix == '.ht2' else str(hisat2_idx) hisat2_cmd = [ f"{RNASEQ_BIN}/hisat2", "-x", idx_prefix, "-1", str(r1), "-2", str(r2), "-p", str(cpu), "--new-summary", ] hisat2_cmd.extend(hisat2_rna_strandness_args(library_strandness)) if known_splice_site: hisat2_cmd.extend(["--known-splicesite-infile", str(known_splice_site)]) sort_cmd = [ f"{RNASEQ_BIN}/samtools", "sort", "-@", str(cpu), "-o", str(sorted_bam), "-", ] sorted_bam.parent.mkdir(parents=True, exist_ok=True) with open(log_out, "w") as log: log.write("Command: " + " ".join(hisat2_cmd) + " | " + " ".join(sort_cmd) + "\n\n") hisat2_proc = subprocess.Popen(hisat2_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) assert hisat2_proc.stdout is not None sort_proc = subprocess.Popen(sort_cmd, stdin=hisat2_proc.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE) hisat2_proc.stdout.close() sort_stdout, sort_stderr = sort_proc.communicate() hisat2_stderr = hisat2_proc.stderr.read() if hisat2_proc.stderr else b"" hisat2_return = hisat2_proc.wait() log.write("--- HISAT2 STDERR ---\n") log.write(hisat2_stderr.decode("utf-8", errors="replace")) log.write("\n--- SAMTOOLS SORT STDOUT ---\n") log.write((sort_stdout or b"").decode("utf-8", errors="replace")) log.write("\n--- SAMTOOLS SORT STDERR ---\n") log.write((sort_stderr or b"").decode("utf-8", errors="replace")) return hisat2_return == 0 and sort_proc.returncode == 0 and sorted_bam.exists() and sorted_bam.stat().st_size > 0 def index_bam(bam: Path) -> bool: """索引BAM""" cmd = [f"{RNASEQ_BIN}/samtools", "index", str(bam)] try: subprocess.run(cmd, check=True, capture_output=True) return True except subprocess.CalledProcessError as e: print(f"samtools index failed: {e.stderr}", file=sys.stderr) return False def run_samtools_qc(bam: Path, flagstat_out: Path, stats_out: Path, cpu: int = 4) -> bool: """Generate samtools flagstat and stats files for an aligned BAM.""" ok = True try: with open(flagstat_out, "w") as out: subprocess.run( [f"{RNASEQ_BIN}/samtools", "flagstat", "-@", str(cpu), str(bam)], check=True, stdout=out, stderr=subprocess.PIPE, text=True, ) except subprocess.CalledProcessError as e: print(f"samtools flagstat failed for {bam.name}: {e.stderr}", file=sys.stderr) ok = False try: with open(stats_out, "w") as out: subprocess.run( [f"{RNASEQ_BIN}/samtools", "stats", "-@", str(cpu), str(bam)], check=True, stdout=out, stderr=subprocess.PIPE, text=True, ) except subprocess.CalledProcessError as e: print(f"samtools stats failed for {bam.name}: {e.stderr}", file=sys.stderr) ok = False return ok def parse_samtools_flagstat(flagstat_file: Path) -> Dict[str, Any]: """Parse a small subset of samtools flagstat metrics.""" stats: Dict[str, Any] = {} if not flagstat_file.exists(): return stats content = flagstat_file.read_text(errors="replace") patterns = { "flagstat_total": r"^(\d+) \+ \d+ in total", "flagstat_mapped": r"^(\d+) \+ \d+ mapped ", "flagstat_properly_paired": r"^(\d+) \+ \d+ properly paired ", "flagstat_duplicates": r"^(\d+) \+ \d+ duplicates", } for key, pattern in patterns.items(): match = re.search(pattern, content, re.MULTILINE) if match: stats[key] = int(match.group(1)) match = re.search(r"\((\d+(?:\.\d+)?)% : N/A\)", content) if match: stats["flagstat_mapped_rate"] = float(match.group(1)) return stats def run_multiqc(search_paths: Sequence[Path], output_dir: Path, report_name: str) -> Optional[Path]: """Run MultiQC over selected stage 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 parse_hisat2_log(log_file: Path) -> Dict[str, Any]: """解析HISAT2日志获取统计信息""" stats = { "total_reads": 0, "aligned_reads": 0, "unaligned_reads": 0, "alignment_rate": 0.0, "unique_alignments": 0, "multiple_alignments": 0 } if not log_file.exists(): return stats with open(log_file, 'r') as f: content = f.read() # 解析总reads数 match = re.search(r'(\d+) reads; of these:', content) if match: stats["total_reads"] = int(match.group(1)) else: match = re.search(r'Total pairs:\s+(\d+)', content) if match: stats["total_reads"] = int(match.group(1)) # 解析比对率 match = re.search(r'([\d.]+)% overall alignment rate', content) if match: stats["alignment_rate"] = float(match.group(1)) else: match = re.search(r'Overall alignment rate:\s+([\d.]+)%', content) if match: stats["alignment_rate"] = float(match.group(1)) # 解析唯一比对和多比对 match = re.search(r'(\d+) \(.*?\) aligned concordantly exactly 1 time', content) if match: stats["unique_alignments"] = int(match.group(1)) else: match = re.search(r'Aligned concordantly 1 time:\s+(\d+)', content) if match: stats["unique_alignments"] = int(match.group(1)) match = re.search(r'(\d+) \(.*?\) aligned concordantly >1 times', content) if match: stats["multiple_alignments"] = int(match.group(1)) else: match = re.search(r'Aligned concordantly >1 times:\s+(\d+)', content) if match: stats["multiple_alignments"] = int(match.group(1)) match = re.search(r'Aligned discordantly 1 time:\s+(\d+)', content) if match: stats["multiple_alignments"] += int(match.group(1)) # 计算未比对 if stats["total_reads"] > 0: aligned = stats["unique_alignments"] + stats["multiple_alignments"] if "Total pairs:" in content: stats["aligned_reads"] = aligned stats["unaligned_reads"] = max(stats["total_reads"] - aligned, 0) else: stats["aligned_reads"] = aligned * 2 # 双端reads stats["unaligned_reads"] = stats["total_reads"] * 2 - stats["aligned_reads"] return stats def resolve_clean_fastq(input_dir: Path, sample_name: str, original_path: Path, mate: int) -> Optional[Path]: """Resolve clean FASTQ generated by rnaseq_qc, including prefixed final/ names.""" candidates = [] original_stem = original_path.name for suffix in (".fastq.gz", ".fq.gz", ".fastq", ".fq", ".gz"): if original_stem.endswith(suffix): original_stem = original_stem[: -len(suffix)] break candidates.extend( [ input_dir / f"{original_stem}.clean.fastq.gz", input_dir / f"{sample_name}_{mate}.clean.fastq.gz", input_dir / f"{sample_name}_R{mate}.clean.fastq.gz", ] ) candidates.extend(sorted(input_dir.glob(f"*.{sample_name}_{mate}.clean.fastq.gz"))) candidates.extend(sorted(input_dir.glob(f"*{sample_name}*_{mate}.clean.fastq.gz"))) candidates.extend(sorted(input_dir.glob(f"*{sample_name}*_R{mate}*.clean.fastq.gz"))) if original_path.exists(): candidates.append(original_path) for candidate in candidates: if candidate.exists() and candidate.stat().st_size > 0: return candidate return None 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 = "", cpu: int = 4, sample_parallel_jobs: int = 1, threads_per_sample: int = 4, sample_config: Optional[str] = None, ) -> Path: """生成比对报告""" report_name = f"{prefix}.rnaseq_align_report.md" if prefix else "rnaseq_align_report.md" report_file = output_dir / report_name elapsed = (end_time - start_time).total_seconds() content = f"""# RNA-Seq Alignment Report ## Summary - **Analysis Date**: {end_time.strftime("%Y-%m-%d %H:%M:%S")} - **Total Samples**: {len(sample_stats)} - **Total Runtime**: {elapsed:.1f} seconds ## Tool Versions """ for tool, version in versions.items(): content += f"- **{tool}**: {version}\n" content += "\n## Sample Alignment Statistics\n\n" content += "| Sample | Total Reads | Aligned | Unaligned | Alignment Rate |\n" content += "|--------|-------------|---------|-----------|----------------|\n" for sample, stats in sample_stats.items(): content += f"| {sample} | {stats.get('total_reads', 'N/A')} | {stats.get('aligned_reads', 'N/A')} | {stats.get('unaligned_reads', 'N/A')} | {stats.get('alignment_rate', '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 = "", cpu: int = 4, sample_parallel_jobs: int = 1, threads_per_sample: int = 4, sample_config: Optional[str] = None, library_strandness: Optional[Dict[str, Any]] = None, splice_site_info: Optional[Dict[str, Any]] = None, ) -> Path: """生成manifest文件""" manifest_name = f"{prefix}.rnaseq_align.manifest.json" if prefix else "rnaseq_align.manifest.json" manifest_file = output_dir / manifest_name total_aligned = sum(s.get('aligned_reads', 0) for s in sample_stats.values()) total_reads = sum(s.get('total_reads', 0) for s in sample_stats.values()) avg_rate = (total_aligned / total_reads * 100) if total_reads > 0 else 0 splice_info = splice_site_info or {} splice_param = "" if splice_info.get("path"): splice_param = f" --known-splicesite-infile {splice_info.get('path')}" manifest = { "manifest_version": "1.0", "stage": "rnaseq_align", "status": "success", "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(output_dir), "input_dir": str(input_dir), "metrics": { "total_samples": len(sample_stats), "total_reads": total_reads, "aligned_reads": total_aligned, "average_alignment_rate": avg_rate, "samples": sample_stats }, "parameters": { "cpu": cpu, "sample_parallel_jobs": sample_parallel_jobs, "threads_per_sample": threads_per_sample, "hisat2_params": f"-p {threads_per_sample} --new-summary{splice_param} | samtools sort", "samtools_params": f"sort/index/flagstat/stats -@ {threads_per_sample}", "splice_site_source": splice_info.get("mode", "auto"), "known_splice_site_status": splice_info.get("status", "not_found"), "known_splice_site": splice_info.get("path", ""), "known_splice_site_input": splice_info.get("source", ""), "known_splice_site_count": splice_info.get("count", 0), "library_strandness_by_sample": (library_strandness or {}).get("library_strandness_by_sample", {}), "library_strandness_values": (library_strandness or {}).get("library_strandness_values", []), "library_strandness_signature": (library_strandness or {}).get("library_strandness_signature", ""), }, "artifacts": {}, "tools": versions } context = build_rnaseq_context( prefix=prefix, sample_config=sample_config, foreground_mode="global", stage="rnaseq_align", ) apply_rnaseq_context(manifest, context) if library_strandness: manifest["rnaseq_library_strandness_by_sample"] = library_strandness.get("library_strandness_by_sample", {}) manifest["rnaseq_library_strandness_values"] = library_strandness.get("library_strandness_values", []) manifest["rnaseq_library_strandness_signature"] = library_strandness.get("library_strandness_signature", "") manifest["rnaseq_library_strandness_mixed"] = library_strandness.get("library_strandness_mixed", False) if splice_info: manifest["rnaseq_known_splice_site"] = splice_info # 添加artifacts(参考rnaseq_qc契约,包含完整字段) 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 } for sample in sample_stats.keys(): # 排序后的BAM文件 - 在output_dir (即final_dir) sorted_bam = output_dir / f"{sample}.sorted.bam" add_artifact(f"sorted_bam_{sample}", sorted_bam, "bam", format="bam") # BAM索引 - 在output_dir bam_index = output_dir / f"{sample}.sorted.bam.bai" add_artifact(f"bam_index_{sample}", bam_index, "bai", format="bai") # HISAT2日志和 samtools QC 文件 - 在output_dir log_file = output_dir / f"{sample}_hisat2.log" add_artifact(f"alignment_log_{sample}", log_file, "log", format="txt") flagstat_file = output_dir / f"{sample}.flagstat.txt" stats_file = output_dir / f"{sample}.samtools.stats.txt" add_artifact(f"samtools_flagstat_{sample}", flagstat_file, "text", format="txt") add_artifact(f"samtools_stats_{sample}", stats_file, "text", format="txt") # 报告、MultiQC和manifest - 在output_dir report_name = f"{prefix}.rnaseq_align_report.md" if prefix else "rnaseq_align_report.md" report_file = output_dir / report_name add_artifact("report", report_file, "markdown", format="md") generated_splice = output_dir / f"{prefix}.hisat2.splice_sites.ss" add_artifact("known_splice_site", generated_splice, "tsv", format="hisat2_splice_site") multiqc_name = f"{prefix}.rnaseq_align_multiqc_report.html" if prefix else "rnaseq_align_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 Alignment") parser.add_argument("-i", "--input", required=True, help="Input directory with clean FASTQ files") parser.add_argument("-x", "--hisat2-index", required=True, help="HISAT2 index prefix") 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("-p", "--prefix", default="", help="Output file prefix") parser.add_argument("--sample-config", help="Sample config YAML for sample list") parser.add_argument( "--splice-site-source", default="auto", choices=["auto", "off", "manual", "annotation"], help="HISAT2 known splice site source. auto/manual use --known-splice-site when provided; annotation extracts .ss from GTF/GFF.", ) parser.add_argument( "--known-splice-site", help="HISAT2 splice site file (.ss/.txt) or annotation GTF/GFF used to generate a splice-site file.", ) 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) hisat2_idx = Path(args.hisat2_index) context_for_prefix = resolve_rnaseq_project_context( input_dir=Path(args.sample_config).parent if args.sample_config else hisat2_idx.parent, final_dir=input_dir, requested_prefix=args.prefix, sample_config=args.sample_config, ) 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_align/analysis/ align_dir = output_dir / "analysis" align_dir.mkdir(exist_ok=True) # 确定样本列表 samples = {} if args.sample_config: import yaml with open(args.sample_config, 'r') as f: config = yaml.safe_load(f) samples = config.get("samples", {}) if not samples: raise ValueError(f"Config file {args.sample_config} must contain 'samples' field") else: # 从输入目录自动检测样本 for f in sorted(input_dir.glob("*_1.clean.fastq.gz")): sample_name = f.stem.replace("_1.clean", "") r1 = f r2 = input_dir / f"{sample_name}_2.clean.fastq.gz" if r2.exists(): samples[sample_name] = (str(r1), str(r2)) print(f"Processing {len(samples)} samples...") sample_names_for_strandness = list(map(str, samples.keys())) try: strandness_info = require_rnaseq_library_strandness(args.sample_config, sample_names_for_strandness) except RnaseqLibraryStrandnessError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) strandness_by_sample = strandness_info.get("library_strandness_by_sample", {}) print(f"Library strandness: {strandness_by_sample}") # 获取工具版本 versions = get_tool_versions() print(f"Tool versions: {versions}") try: hisat2_idx = resolve_locked_hisat2_index( requested_index=hisat2_idx, input_dir=input_dir, align_dir=align_dir, prefix=args.prefix, sample_config=args.sample_config, cpu=threads_per_sample, ) print(f"Using locked HISAT2 genome index prefix: {hisat2_idx}") except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) try: known_splice_site, splice_site_info = resolve_known_splice_site( source_mode=args.splice_site_source, known_splice_site=args.known_splice_site, final_dir=final_dir, prefix=args.prefix, ) except Exception as e: print(f"Error: failed to prepare HISAT2 known splice sites: {e}", file=sys.stderr) sys.exit(1) if known_splice_site: print( f"Using HISAT2 known splice sites: {known_splice_site} " f"(source={splice_site_info.get('source')}, count={splice_site_info.get('count')})" ) else: print(f"HISAT2 known splice sites: {splice_site_info.get('status', 'not_found')}") sample_stats = {} def align_one_sample(sample_name: str, paths: Any) -> tuple[str, Optional[Dict[str, Any]], Optional[str]]: # 支持两种格式:列表 [r1, r2] 或字典 {"r1": ..., "r2": ...} if isinstance(paths, list): if len(paths) < 2: return sample_name, None, f"Sample {sample_name} has insufficient paths" 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: return sample_name, None, f"Sample {sample_name} missing r1/r2 paths" else: return sample_name, None, f"Unknown format for sample {sample_name}" r1 = Path(r1_path) r2 = Path(r2_path) # 如果路径是相对路径,从input_dir查找 if not r1.is_absolute(): r1 = input_dir / r1.name if not r2.is_absolute(): r2 = input_dir / r2.name r1 = resolve_clean_fastq(input_dir, sample_name, r1, 1) or r1 r2 = resolve_clean_fastq(input_dir, sample_name, r2, 2) or r2 if not r1.exists() or not r2.exists(): return sample_name, None, f"FASTQ files not found for sample {sample_name}: R1={r1}, R2={r2}" print(f"Aligning sample: {sample_name}") print(f" R1: {r1}") print(f" R2: {r2}") sample_strandness = strandness_by_sample.get(str(sample_name), "") print(f" library_strandness: {sample_strandness}") # HISAT2 比对直接 pipe 到 samtools sort,减少 SAM/BAM 中间落盘 log_file = align_dir / f"{sample_name}_hisat2.log" sorted_bam = final_dir / f"{sample_name}.sorted.bam" if not run_hisat2_sort( hisat2_idx, r1, r2, sorted_bam, log_file, threads_per_sample, sample_strandness, known_splice_site=known_splice_site, ): return sample_name, None, f"HISAT2/samtools sort failed for sample {sample_name}" # 索引BAM、生成 samtools QC 文件 index_bam(sorted_bam) flagstat_file = final_dir / f"{sample_name}.flagstat.txt" samtools_stats_file = final_dir / f"{sample_name}.samtools.stats.txt" run_samtools_qc(sorted_bam, flagstat_file, samtools_stats_file, threads_per_sample) # 复制日志到final_dir final_log = final_dir / f"{sample_name}_hisat2.log" shutil.copy2(str(log_file), str(final_log)) # 解析日志和 samtools flagstat 获取统计 stats = parse_hisat2_log(log_file) stats.update(parse_samtools_flagstat(flagstat_file)) 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']}" ) max_workers = max(1, int(thread_plan["sample_parallel_jobs"])) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(align_one_sample, sample_name, paths): sample_name for sample_name, paths in 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"Warning: sample {sample_name} failed with exception: {exc}", file=sys.stderr) continue if error: print(f"Warning: {error}", file=sys.stderr) continue if stats is not None: sample_stats[completed_name] = stats # 生成 MultiQC 报告 multiqc_report = run_multiqc([align_dir, final_dir], final_dir, f"{args.prefix}.rnaseq_align_multiqc_report.html" if args.prefix else "rnaseq_align_multiqc_report.html") if multiqc_report: print(f"MultiQC report: {multiqc_report}") # 生成报告 if not sample_stats: print("Error: no samples were aligned. Check clean FASTQ names and samples.yaml.", file=sys.stderr) sys.exit(1) end_time = datetime.now() generate_report(final_dir, sample_stats, versions, start_time, end_time, args.prefix) # 生成manifest cmd = ["python", __file__, "-i", str(input_dir), "-x", str(hisat2_idx), "-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"])] if args.sample_config: cmd.extend(["--sample-config", str(args.sample_config)]) cmd.extend(["--splice-site-source", args.splice_site_source]) if args.known_splice_site: cmd.extend(["--known-splice-site", str(args.known_splice_site)]) generate_manifest( final_dir, sample_stats, versions, start_time, end_time, cmd, input_dir, output_dir, args.job_id, args.run_id, args.prefix, cpu=args.cpu, sample_parallel_jobs=thread_plan["sample_parallel_jobs"], threads_per_sample=threads_per_sample, sample_config=args.sample_config, library_strandness=strandness_info, splice_site_info=splice_site_info, ) print(f"RNA-ALN completed. Results in {output_dir}") if __name__ == "__main__": main()