Files
PlantAI-dev/apps/cli/run_rnaseq_quant.py
T

2076 lines
87 KiB
Python
Raw Normal View History

2026-08-03 18:42:47 +08:00
#!/home/xzq/miniconda3/envs/test4py311/bin/python
"""
RNA-Seq Quantification阶段:对BAM文件进行定量分析
输入:
- 排序后的BAM文件(来自RNA_ALN阶段)
- 参考基因组GTF注释
输出:
- 基因计数矩阵
- FPKM/TPM表达矩阵
- 定量报告和manifest
"""
import argparse
import json
import subprocess
import sys
import time
import yaml
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
import shutil
from gfa_core.rnaseq_context import (
RnaseqLibraryStrandnessError,
apply_rnaseq_context,
build_rnaseq_context,
featurecounts_strandness_value,
require_rnaseq_library_strandness,
resolve_rnaseq_project_context,
)
from apps.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 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 get_tool_versions() -> Dict[str, str]:
"""获取所需工具版本信息"""
versions = {}
# stringtie
try:
result = subprocess.run(
[f"{RNASEQ_BIN}/stringtie", "--version"],
capture_output=True,
text=True,
check=False,
timeout=5
)
output = result.stdout.strip() or result.stderr.strip()
versions["stringtie"] = output.strip() if output else "unknown"
except Exception:
versions["stringtie"] = "not_found"
# featureCounts
try:
result = subprocess.run(
[f"{RNASEQ_BIN}/featureCounts", "-v"],
capture_output=True,
text=True,
check=False,
timeout=5
)
output = result.stderr.strip() or result.stdout.strip()
if "featureCounts" in output:
versions["featureCounts"] = output.split('\n')[0]
else:
versions["featureCounts"] = "not_found"
except Exception:
versions["featureCounts"] = "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"
for tool, cmd in {
"salmon": [f"{RNASEQ_BIN}/salmon", "--version"],
"kallisto": [f"{RNASEQ_BIN}/kallisto", "version"],
"rsem": [f"{RNASEQ_BIN}/rsem-calculate-expression", "--version"],
}.items():
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=10)
output = result.stdout.strip() or result.stderr.strip()
versions[tool] = output.split('\n')[0] if output else "unknown"
except Exception:
versions[tool] = "not_found"
try:
result = subprocess.run(
[f"{RNASEQ_BIN}/Rscript", "-e", "cat(requireNamespace('tximport', quietly=TRUE))"],
capture_output=True,
text=True,
check=False,
timeout=20,
)
versions["tximport"] = "available" if result.stdout.strip() == "TRUE" else "not_found"
except Exception:
versions["tximport"] = "not_found"
return versions
def resolve_sample_bam(input_dir: Path, sample_name: str, sample_value: Any) -> Optional[Path]:
"""Resolve BAM path from old BAM configs or shared samples.yaml r1/r2 configs."""
if isinstance(sample_value, str):
bam = Path(sample_value)
return bam if bam.is_absolute() else input_dir / bam
if isinstance(sample_value, dict):
bam_value = sample_value.get("bam") or sample_value.get("bam_path") or sample_value.get("sorted_bam")
if bam_value:
bam = Path(str(bam_value))
return bam if bam.is_absolute() else input_dir / bam
return input_dir / f"{sample_name}.sorted.bam"
return None
def run_stringtie(
bam: Path,
gtf: Path,
output_gtf: Path,
output_cov: Path,
cpu: int = 4
) -> bool:
"""运行StringTie进行转录本组装和定量"""
cmd = [
f"{RNASEQ_BIN}/stringtie",
str(bam),
"-G", str(gtf),
"-o", str(output_gtf),
"-A", str(output_cov),
"-p", str(cpu)
]
try:
subprocess.run(cmd, check=True, capture_output=True)
return True
except subprocess.CalledProcessError as e:
print(f"StringTie failed: {e.stderr}", file=sys.stderr)
return False
def convert_gff3_to_gtf(gff3: Path, output_dir: Path) -> Optional[Path]:
"""将GFF3转换为GTF格式"""
gtf_output = output_dir / f"{gff3.stem}.converted.gtf"
# 使用 gffread 转换(如果可用)
gffread_cmd = [f"{RNASEQ_BIN}/gffread", str(gff3), "-T", "-o", str(gtf_output)]
try:
result = subprocess.run(gffread_cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0 and gtf_output.exists() and gtf_output.stat().st_size > 100:
print(f"[INFO] Converted {gff3.name} to GTF using gffread")
return gtf_output
except Exception:
pass
# 如果 gffread 失败,使用 Python 转换
# 注意:需要正确处理基因层次结构
print(f"[INFO] Converting {gff3.name} to GTF format with gene hierarchy...")
try:
# 第一遍:收集基因和转录本信息
genes = {} # ID -> gene_id
transcripts = {} # ID -> (gene_id, transcript_id)
with open(gff3, 'r') as f_in:
for line in f_in:
if line.startswith('#') or line.strip() == '':
continue
parts = line.strip().split('\t')
if len(parts) < 9:
continue
feature_type = parts[2]
attr = parts[8]
# 解析属性
id_val = None
parent_val = None
name_val = None
for item in attr.split(';'):
item = item.strip()
if item.startswith('ID='):
id_val = item[3:]
elif item.startswith('Parent='):
parent_val = item[7:]
elif item.startswith('Name='):
name_val = item[5:]
# 记录基因和转录本
if feature_type == 'gene' and id_val:
genes[id_val] = name_val or id_val
elif feature_type == 'mRNA' and id_val:
# 转录本的Parent是基因
gene_id = genes.get(parent_val, parent_val)
transcripts[id_val] = (gene_id, name_val or id_val)
# 第二遍:写入GTF
with open(gff3, 'r') as f_in, open(gtf_output, 'w') as f_out:
for line in f_in:
if line.startswith('#') or line.strip() == '':
continue
parts = line.strip().split('\t')
if len(parts) < 9:
continue
feature_type = parts[2]
attr = parts[8]
# 解析属性
id_val = None
parent_val = None
for item in attr.split(';'):
item = item.strip()
if item.startswith('ID='):
id_val = item[3:]
elif item.startswith('Parent='):
parent_val = item[7:]
# 确定gene_id和transcript_id
gene_id = None
transcript_id = None
if feature_type == 'gene':
gene_id = genes.get(id_val, id_val)
transcript_id = gene_id
elif feature_type == 'mRNA':
gene_id, transcript_id = transcripts.get(id_val, (parent_val, id_val))
elif feature_type in ('exon', 'CDS', 'five_prime_utr', 'three_prime_utr'):
# 这些特征的Parent是转录本
if parent_val in transcripts:
gene_id, transcript_id = transcripts[parent_val]
else:
# Parent可能是基因(直接连接的exon)
gene_id = genes.get(parent_val, parent_val)
transcript_id = gene_id
if gene_id:
new_attr = f'gene_id "{gene_id}"; transcript_id "{transcript_id}"'
parts[8] = new_attr
f_out.write('\t'.join(parts) + '\n')
if gtf_output.exists() and gtf_output.stat().st_size > 100:
print(f"[INFO] Converted {gff3.name} to {gtf_output}")
return gtf_output
except Exception as e:
print(f"[WARN] GFF3 conversion failed: {e}")
import traceback
traceback.print_exc()
return None
def run_featurecounts(
bam_files: List[Path],
gtf: Path,
output: Path,
cpu: int = 4,
library_strandness: str = "non_stranded",
command_log: Optional[Path] = None,
) -> Optional[Path]:
"""运行featureCounts进行基因计数"""
# 检测GTF/GFF格式
gtf_suffix = gtf.suffix.lower()
annotation_file = gtf
# 如果是GFF3,先转换为GTF
if gtf_suffix in ['.gff', '.gff3'] or 'gff' in gtf.name.lower():
converted = convert_gff3_to_gtf(gtf, output.parent)
if converted:
annotation_file = converted
print(f"[INFO] Using converted GTF: {annotation_file}")
else:
print(f"[WARN] GFF3 conversion failed, trying original file...")
cmd = [
f"{RNASEQ_BIN}/featureCounts",
"-t", "exon",
"-g", "gene_id",
"-a", str(annotation_file),
"-o", str(output),
"-p", # paired-end
"--countReadPairs", # count fragments/read pairs with Subread >=2.0
"-B", # require both ends
"-C", # exclude chimeric
"-s", featurecounts_strandness_value(library_strandness),
"-T", str(min(cpu, 64))
]
cmd.extend([str(b) for b in bam_files])
if command_log:
command_log.parent.mkdir(parents=True, exist_ok=True)
command_log.write_text("Command: " + " ".join(cmd) + "\n", encoding="utf-8")
try:
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
return annotation_file
except subprocess.CalledProcessError as e:
print(f"featureCounts failed: {e.stderr}", file=sys.stderr)
return None
def run_multiqc(search_paths: Sequence[Path], output_dir: Path, report_name: str) -> Optional[Path]:
"""Run MultiQC over quantification 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_gff3_attrs(attr_text: str) -> Dict[str, str]:
return parse_annotation_attrs(attr_text)
def parse_annotation_attrs(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('"')
continue
match = re.match(r"^(\S+)\s+\"([^\"]+)\"$", item)
if match:
attrs[match.group(1)] = match.group(2)
continue
match = re.match(r"^(\S+)\s+(.+)$", item)
if match:
attrs[match.group(1)] = match.group(2).strip().strip('"')
return attrs
def first_annotation_value(attrs: Dict[str, str], keys: Sequence[str]) -> Optional[str]:
for key in keys:
value = attrs.get(key)
if value:
return str(value).split(",")[0].strip()
return None
def extract_gene_id_from_fasta_header(raw_header: str) -> Optional[str]:
for key in ("gene", "gene_id", "Parent", "locus_tag", "Name"):
match = re.search(rf"(?:^|\s){re.escape(key)}[:=]([^\s;]+)", raw_header)
if match:
return match.group(1).split(",")[0].strip()
return None
def parse_tx2gene_from_gff(annotation: Path) -> Dict[str, str]:
"""Parse transcript-to-gene mapping from a GFF3/GTF-like annotation."""
tx2gene: Dict[str, str] = {}
if not annotation.exists():
return tx2gene
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:
continue
feature_type = parts[2].lower()
attrs = parse_annotation_attrs(parts[8])
if feature_type in {"mrna", "transcript"}:
tx_id = first_annotation_value(attrs, ("ID", "transcript_id", "Name"))
gene_id = first_annotation_value(attrs, ("Parent", "gene_id", "gene", "locus_tag", "Name"))
if tx_id and gene_id:
tx2gene[tx_id] = gene_id
elif feature_type == "cds":
tx_id = first_annotation_value(attrs, ("transcript_id", "Parent", "ID", "protein_id"))
gene_id = first_annotation_value(attrs, ("gene_id", "gene", "locus_tag", "Parent", "Name"))
if tx_id and gene_id:
tx2gene.setdefault(tx_id, gene_id)
return tx2gene
def normalize_transcriptome_and_tx2gene(
transcriptome_fasta: Path,
annotation: Optional[Path],
output_dir: Path,
prefix: str,
) -> Tuple[Optional[Path], Optional[Path], Dict[str, Any]]:
"""Write a Salmon-safe transcript FASTA and a tx2gene table."""
if not transcriptome_fasta.exists():
return None, None, {"status": "skipped", "reason": "transcriptome_fasta_not_found"}
gff_tx2gene = parse_tx2gene_from_gff(annotation) if annotation else {}
normalized_fasta = output_dir / f"{prefix}.salmon.transcripts.fa"
tx2gene_file = output_dir / f"{prefix}.salmon.tx2gene.tsv"
transcript_count = 0
header_gene_count = 0
annotation_gene_count = 0
fallback_gene_count = 0
seen: set = set()
write_sequence = False
with open(transcriptome_fasta, "r", encoding="utf-8", errors="ignore") as inp, \
open(normalized_fasta, "w", encoding="utf-8") as fasta_out, \
open(tx2gene_file, "w", encoding="utf-8") as tx_out:
tx_out.write("transcript_id\tgene_id\n")
for line in inp:
if line.startswith(">"):
raw_header = line[1:].strip()
tx_id = raw_header.split()[0]
write_sequence = bool(tx_id and tx_id not in seen)
if not write_sequence:
continue
seen.add(tx_id)
gene_id = None
header_gene_id = extract_gene_id_from_fasta_header(raw_header)
if header_gene_id:
gene_id = header_gene_id
header_gene_count += 1
elif tx_id in gff_tx2gene:
gene_id = gff_tx2gene[tx_id]
annotation_gene_count += 1
else:
gene_id = re.sub(r"(\.\d+|-RA|-RB|-RC|-T\d+)$", "", tx_id)
fallback_gene_count += 1
transcript_count += 1
fasta_out.write(f">{tx_id}\n")
tx_out.write(f"{tx_id}\t{gene_id}\n")
elif write_sequence:
fasta_out.write(line)
if transcript_count == 0:
return None, None, {"status": "failed", "reason": "no_transcripts_in_fasta"}
fallback_pct = (fallback_gene_count / transcript_count * 100.0) if transcript_count else 0.0
return normalized_fasta, tx2gene_file, {
"status": "prepared",
"source_transcriptome": str(transcriptome_fasta),
"transcripts": transcript_count,
"tx2gene_rows": transcript_count,
"tx2gene_fallback_pct": round(fallback_pct, 3),
"warning": "tx2gene_fallback_gt_5pct" if fallback_pct > 5.0 else "",
"gene_id_sources": {
"fasta_gene_field": header_gene_count,
"annotation": annotation_gene_count,
"transcript_id_fallback": fallback_gene_count,
},
}
def read_gene_matrix_signature(matrix_file: Optional[Path]) -> Dict[str, Any]:
signature: Dict[str, Any] = {"exists": False, "samples": [], "genes": [], "column_sums": {}}
if not matrix_file or not matrix_file.exists():
return signature
signature["exists"] = True
with open(matrix_file, "r", encoding="utf-8", errors="ignore") as handle:
header = handle.readline().rstrip("\n").split("\t")
signature["samples"] = header[1:] if len(header) > 1 else []
sums = {sample: 0.0 for sample in signature["samples"]}
genes: List[str] = []
for line in handle:
if not line.strip():
continue
parts = line.rstrip("\n").split("\t")
gene_id = parts[0]
if gene_id:
genes.append(gene_id)
for idx, sample in enumerate(signature["samples"], start=1):
try:
sums[sample] += float(parts[idx]) if idx < len(parts) and parts[idx] != "" else 0.0
except ValueError:
pass
signature["genes"] = genes
signature["column_sums"] = sums
return signature
def read_tx2gene_stats(tx2gene_file: Optional[Path]) -> Dict[str, Any]:
stats = {"exists": False, "rows": 0, "unique_transcripts": 0, "unique_gene_ids": 0}
if not tx2gene_file or not tx2gene_file.exists():
return stats
transcripts = set()
genes = set()
with open(tx2gene_file, "r", encoding="utf-8", errors="ignore") as handle:
first = True
for line in handle:
if not line.strip():
continue
parts = line.rstrip("\n").split("\t")
if first and len(parts) >= 2 and parts[0].lower() in {"tx", "transcript", "transcript_id"}:
first = False
continue
first = False
if len(parts) < 2:
continue
stats["rows"] += 1
transcripts.add(parts[0])
genes.add(parts[1])
stats.update({"exists": True, "unique_transcripts": len(transcripts), "unique_gene_ids": len(genes)})
return stats
def parse_salmon_index_duplicate_count(index_dir: Optional[Path]) -> Dict[str, Any]:
info: Dict[str, Any] = {"duplicate_transcripts_removed": 0, "keep_duplicates": None}
if not index_dir or not index_dir.exists():
return info
info_file = index_dir / "info.json"
if info_file.exists():
try:
data = json.loads(info_file.read_text(encoding="utf-8", errors="ignore"))
if isinstance(data, dict):
info["keep_duplicates"] = data.get("keep_duplicates")
except Exception:
pass
for log_file in [index_dir / "ref_indexing.log", *sorted(index_dir.glob("*.log"))]:
if not log_file.exists():
continue
text = log_file.read_text(encoding="utf-8", errors="ignore")
match = re.search(r"Removed\s+(\d+)\s+transcripts\s+that\s+were\s+sequence\s+duplicates", text)
if match:
info["duplicate_transcripts_removed"] = int(match.group(1))
break
return info
def format_metric_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return ""
if isinstance(value, float):
return f"{value:.6f}".rstrip("0").rstrip(".")
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return str(value)
def write_matrix_bridge_qc(qc: Dict[str, Any], output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as handle:
handle.write("metric\tvalue\n")
for key in sorted(qc.keys()):
handle.write(f"{key}\t{format_metric_value(qc[key])}\n")
def compute_matrix_bridge_qc(
count_matrix: Path,
abundance_tpm: Optional[Path],
tx2gene: Optional[Path],
engine: str = "salmon",
salmon_index_dir: Optional[Path] = None,
tx2gene_metrics: Optional[Dict[str, Any]] = None,
output_path: Optional[Path] = None,
overlap_threshold_pct: float = 95.0,
tpm_sum_tolerance: float = 1.0,
) -> Dict[str, Any]:
count_sig = read_gene_matrix_signature(count_matrix)
abundance_sig = read_gene_matrix_signature(abundance_tpm)
count_genes = set(count_sig.get("genes", []))
abundance_genes = set(abundance_sig.get("genes", []))
overlap = len(count_genes & abundance_genes)
overlap_vs_featurecounts = (overlap / len(count_genes) * 100.0) if count_genes else 0.0
overlap_vs_abundance = (overlap / len(abundance_genes) * 100.0) if abundance_genes else 0.0
sample_columns_match = count_sig.get("samples") == abundance_sig.get("samples")
tpm_sums = abundance_sig.get("column_sums", {})
tpm_sum_deviation_max = max((abs(float(value) - 1_000_000.0) for value in tpm_sums.values()), default=0.0)
tpm_sums_ok = bool(tpm_sums) and tpm_sum_deviation_max <= tpm_sum_tolerance
tx2gene_stats = read_tx2gene_stats(tx2gene)
tx2gene_metrics = tx2gene_metrics or {}
fallback_pct = float(tx2gene_metrics.get("tx2gene_fallback_pct") or 0.0)
abundance_exists = bool(abundance_tpm and abundance_tpm.exists())
status = "pass"
failure_reasons: List[str] = []
if not count_sig.get("exists"):
failure_reasons.append("count_matrix_missing")
if not abundance_exists:
failure_reasons.append(f"{engine}_tpm_missing")
if abundance_exists and not sample_columns_match:
failure_reasons.append("sample_columns_mismatch")
if abundance_exists and overlap_vs_featurecounts < overlap_threshold_pct:
failure_reasons.append("gene_overlap_below_95pct")
if abundance_exists and not tpm_sums_ok:
failure_reasons.append("tpm_column_sums_not_1e6")
if fallback_pct > 5.0:
failure_reasons.append("tx2gene_fallback_gt_5pct")
if failure_reasons:
status = "fallback"
recommended = f"{engine}_tximport_tpm" if status == "pass" else "featurecounts_tpm"
qc: Dict[str, Any] = {
"status": status,
"engine": engine,
"count_matrix": str(count_matrix),
"abundance_tpm": str(abundance_tpm) if abundance_tpm else "",
"tx2gene": str(tx2gene) if tx2gene else "",
"featurecounts_genes": len(count_genes),
"abundance_genes": len(abundance_genes),
"gene_id_overlap": overlap,
"gene_overlap_vs_featurecounts_pct": round(overlap_vs_featurecounts, 3),
"gene_overlap_vs_abundance_pct": round(overlap_vs_abundance, 3),
"sample_columns_match": sample_columns_match,
"featurecounts_samples": count_sig.get("samples", []),
"abundance_samples": abundance_sig.get("samples", []),
"tpm_column_sums": {sample: round(float(value), 6) for sample, value in tpm_sums.items()},
"tpm_column_sums_within_tolerance": tpm_sums_ok,
"tpm_sum_tolerance": tpm_sum_tolerance,
"tx2gene_rows": tx2gene_stats.get("rows", 0),
"tx2gene_unique_gene_ids": tx2gene_stats.get("unique_gene_ids", 0),
"tx2gene_fallback_pct": round(fallback_pct, 3),
"recommended_expression_tpm_source": recommended,
"recommended_expression_tpm_file": str(abundance_tpm) if status == "pass" and abundance_tpm else str(count_matrix.with_name(count_matrix.name.replace(".count_matrix.", ".tpm_matrix."))),
"failure_reasons": failure_reasons,
"display_evidence_policy": "DE uses featureCounts raw counts; expression abundance plots may use tximport TPM only when this bridge QC passes.",
}
if engine == "salmon":
qc.update(parse_salmon_index_duplicate_count(salmon_index_dir))
if output_path:
write_matrix_bridge_qc(qc, output_path)
qc["qc_file"] = str(output_path)
return qc
def auto_find_transcriptome(workspace_dir: Path, input_dir: Path, gtf: Path, species_prefix: str) -> Optional[Path]:
search_dirs = [gtf.parent, input_dir, workspace_dir / "input", Path("input") / workspace_dir.name]
patterns = [
f"{species_prefix}.cds.fasta",
f"{species_prefix}.cds.fa",
"*.cds.fasta",
"*.cds.fa",
"*cds*.fasta",
"*cds*.fa",
"*transcript*.fasta",
"*transcript*.fa",
]
for base in search_dirs:
if not base.exists():
continue
for pattern in patterns:
for candidate in sorted(base.glob(pattern)):
if candidate.exists() and candidate.stat().st_size > 0:
return candidate
return None
def resolve_sample_fastqs(
input_dir: Path,
workspace_dir: Path,
prefix: str,
sample_name: str,
sample_value: Any,
) -> Tuple[Optional[Path], Optional[Path]]:
def resolve_path(value: str) -> Optional[Path]:
path = Path(value)
candidates = [path] if path.is_absolute() else [
input_dir / path,
workspace_dir / "rnaseq_qc" / "analysis" / path.name,
workspace_dir / "final" / path.name,
Path("input") / workspace_dir.name / path,
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
if isinstance(sample_value, dict):
r1_value = sample_value.get("clean_r1") or sample_value.get("r1_clean") or sample_value.get("r1")
r2_value = sample_value.get("clean_r2") or sample_value.get("r2_clean") or sample_value.get("r2")
r1 = resolve_path(str(r1_value)) if r1_value else None
r2 = resolve_path(str(r2_value)) if r2_value else None
if r1 and r2:
return r1, r2
search_dirs = [input_dir, workspace_dir / "final", workspace_dir / "rnaseq_qc" / "analysis"]
r1_patterns = [
f"{prefix}.rnaseq_qc.{sample_name}_1.clean.fastq.gz",
f"{sample_name}_1.clean.fastq.gz",
f"{sample_name}*_1.clean.fastq.gz",
f"{sample_name}*_R1*.fastq.gz",
f"{sample_name}*_1.fq.gz",
]
r2_patterns = [
f"{prefix}.rnaseq_qc.{sample_name}_2.clean.fastq.gz",
f"{sample_name}_2.clean.fastq.gz",
f"{sample_name}*_2.clean.fastq.gz",
f"{sample_name}*_R2*.fastq.gz",
f"{sample_name}*_2.fq.gz",
]
for base in search_dirs:
if not base.exists():
continue
r1 = next((p for pattern in r1_patterns for p in sorted(base.glob(pattern)) if p.exists()), None)
r2 = next((p for pattern in r2_patterns for p in sorted(base.glob(pattern)) if p.exists()), None)
if r1 and r2:
return r1, r2
return None, None
def run_salmon_abundance_track(
samples: Dict[str, Any],
input_dir: Path,
workspace_dir: Path,
final_dir: Path,
analysis_dir: Path,
prefix: str,
gtf: Path,
transcriptome_fasta: Optional[Path],
tx2gene_override: Optional[Path],
threads: int,
libtype: str,
counts_from_abundance: str,
) -> Dict[str, Any]:
salmon = Path(f"{RNASEQ_BIN}/salmon")
rscript = Path(f"{RNASEQ_BIN}/Rscript")
if not salmon.exists():
return {"engine": "salmon", "status": "skipped", "reason": "salmon_not_found"}
if not rscript.exists():
return {"engine": "salmon", "status": "skipped", "reason": "Rscript_not_found"}
species_prefix = prefix.split(".")[0] if prefix else ""
transcriptome = transcriptome_fasta or auto_find_transcriptome(workspace_dir, input_dir, gtf, species_prefix)
if not transcriptome:
return {"engine": "salmon", "status": "skipped", "reason": "transcriptome_fasta_not_found"}
abundance_dir = analysis_dir / "salmon"
abundance_dir.mkdir(parents=True, exist_ok=True)
normalized_fasta = None
tx2gene_file = tx2gene_override
tx2gene_metrics: Dict[str, Any] = {}
if not tx2gene_file:
normalized_fasta, tx2gene_file, tx2gene_metrics = normalize_transcriptome_and_tx2gene(
transcriptome, gtf, abundance_dir, prefix
)
else:
normalized_fasta = transcriptome
tx2gene_metrics = {"status": "provided", "tx2gene": str(tx2gene_file)}
if not normalized_fasta or not tx2gene_file:
return {"engine": "salmon", "status": "skipped", "reason": "tx2gene_or_transcriptome_prepare_failed", "tx2gene": tx2gene_metrics}
salmon_env = os.environ.copy()
salmon_env["LC_ALL"] = "C"
salmon_env["LANG"] = "C"
salmon_env["LANGUAGE"] = "C"
index_dir = abundance_dir / "salmon_index"
if not (index_dir / "versionInfo.json").exists():
if index_dir.exists():
shutil.rmtree(index_dir)
index_cmd = [str(salmon), "index", "-t", str(normalized_fasta), "-i", str(index_dir), "-k", "31"]
result = subprocess.run(index_cmd, capture_output=True, text=True, check=False, env=salmon_env)
if result.returncode != 0:
return {
"engine": "salmon",
"status": "failed",
"reason": "salmon_index_failed",
"stderr": result.stderr[-2000:],
"cmd": index_cmd,
"tx2gene": tx2gene_metrics,
}
sample_quant_file = abundance_dir / f"{prefix}.salmon.sample_quant_files.tsv"
quant_records: List[Dict[str, str]] = []
failed_samples: Dict[str, str] = {}
for sample_name, sample_value in samples.items():
r1, r2 = resolve_sample_fastqs(input_dir, workspace_dir, prefix, str(sample_name), sample_value)
if not r1 or not r2:
failed_samples[str(sample_name)] = "clean_fastq_pair_not_found"
continue
out_dir = abundance_dir / str(sample_name)
quant_cmd = [
str(salmon), "quant",
"-i", str(index_dir),
"-l", libtype,
"-1", str(r1),
"-2", str(r2),
"--validateMappings",
"--seqBias",
"--gcBias",
"-p", str(max(1, threads)),
"-o", str(out_dir),
]
result = subprocess.run(quant_cmd, capture_output=True, text=True, check=False, env=salmon_env)
quant_file = out_dir / "quant.sf"
if result.returncode == 0 and quant_file.exists():
quant_records.append({"sample": str(sample_name), "quant_file": str(quant_file)})
else:
failed_samples[str(sample_name)] = (result.stderr or result.stdout or "salmon_quant_failed")[-1000:]
if not quant_records:
return {
"engine": "salmon",
"status": "failed",
"reason": "no_successful_salmon_quant",
"failed_samples": failed_samples,
"tx2gene": tx2gene_metrics,
}
with open(sample_quant_file, "w", encoding="utf-8") as handle:
handle.write("sample\tquant_file\n")
for record in quant_records:
handle.write(f"{record['sample']}\t{record['quant_file']}\n")
script = Path("scripts/tximport_abundance.R")
tximport_cmd = [
str(rscript), str(script),
"--samples", str(sample_quant_file),
"--tx2gene", str(tx2gene_file),
"--output", str(final_dir),
"--prefix", prefix,
"--engine", "salmon",
"--counts-from-abundance", counts_from_abundance,
]
result = subprocess.run(tximport_cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return {
"engine": "salmon",
"status": "failed",
"reason": "tximport_failed",
"stderr": result.stderr[-2000:],
"cmd": tximport_cmd,
"successful_samples": len(quant_records),
"failed_samples": failed_samples,
"tx2gene": tx2gene_metrics,
}
final_tx2gene = final_dir / f"{prefix}.salmon.tx2gene.tsv"
if tx2gene_file.exists():
shutil.copy2(tx2gene_file, final_tx2gene)
return {
"engine": "salmon",
"status": "success" if not failed_samples else "partial_success",
"role": "publication_expression_plots",
"successful_samples": len(quant_records),
"failed_samples": failed_samples,
"libtype": libtype,
"bias_correction": {"seqBias": True, "gcBias": True},
"counts_from_abundance": counts_from_abundance,
"transcriptome_fasta": str(transcriptome),
"normalized_transcriptome_fasta": str(normalized_fasta),
"index_dir": str(index_dir),
"tx2gene": tx2gene_metrics,
"abundance_track_warning": tx2gene_metrics.get("warning", ""),
"artifacts": {
"tpm_matrix": f"final/{prefix}.salmon.tpm_matrix.txt",
"tximport_counts": f"final/{prefix}.salmon.tximport_counts.txt",
"length_matrix": f"final/{prefix}.salmon.length_matrix.txt",
"tx2gene": f"final/{prefix}.salmon.tx2gene.tsv",
},
}
def run_kallisto_abundance_track(
samples: Dict[str, Any],
input_dir: Path,
workspace_dir: Path,
final_dir: Path,
analysis_dir: Path,
prefix: str,
gtf: Path,
transcriptome_fasta: Optional[Path],
tx2gene_override: Optional[Path],
threads: int,
counts_from_abundance: str,
bias: bool = False,
) -> Dict[str, Any]:
kallisto = Path(f"{RNASEQ_BIN}/kallisto")
rscript = Path(f"{RNASEQ_BIN}/Rscript")
if not kallisto.exists():
return {"engine": "kallisto", "status": "skipped", "reason": "kallisto_not_found"}
if not rscript.exists():
return {"engine": "kallisto", "status": "skipped", "reason": "Rscript_not_found"}
species_prefix = prefix.split(".")[0] if prefix else ""
transcriptome = transcriptome_fasta or auto_find_transcriptome(workspace_dir, input_dir, gtf, species_prefix)
if not transcriptome:
return {"engine": "kallisto", "status": "skipped", "reason": "transcriptome_fasta_not_found"}
abundance_dir = analysis_dir / "kallisto"
abundance_dir.mkdir(parents=True, exist_ok=True)
normalized_fasta = None
tx2gene_file = tx2gene_override
tx2gene_metrics: Dict[str, Any] = {}
if not tx2gene_file:
normalized_fasta, tx2gene_file, tx2gene_metrics = normalize_transcriptome_and_tx2gene(
transcriptome, gtf, abundance_dir, prefix
)
else:
normalized_fasta = transcriptome
tx2gene_metrics = {"status": "provided", "tx2gene": str(tx2gene_file)}
if not normalized_fasta or not tx2gene_file:
return {"engine": "kallisto", "status": "skipped", "reason": "tx2gene_or_transcriptome_prepare_failed", "tx2gene": tx2gene_metrics}
index_file = abundance_dir / f"{prefix}.kallisto.idx"
if not index_file.exists():
index_cmd = [str(kallisto), "index", "-i", str(index_file), str(normalized_fasta)]
result = subprocess.run(index_cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return {
"engine": "kallisto",
"status": "failed",
"reason": "kallisto_index_failed",
"stderr": result.stderr[-2000:],
"cmd": index_cmd,
"tx2gene": tx2gene_metrics,
}
sample_quant_file = abundance_dir / f"{prefix}.kallisto.sample_quant_files.tsv"
quant_records: List[Dict[str, str]] = []
failed_samples: Dict[str, str] = {}
for sample_name, sample_value in samples.items():
r1, r2 = resolve_sample_fastqs(input_dir, workspace_dir, prefix, str(sample_name), sample_value)
if not r1 or not r2:
failed_samples[str(sample_name)] = "clean_fastq_pair_not_found"
continue
out_dir = abundance_dir / str(sample_name)
quant_cmd = [
str(kallisto), "quant",
"-i", str(index_file),
"-o", str(out_dir),
"-t", str(max(1, threads)),
]
if bias:
quant_cmd.append("--bias")
quant_cmd.extend([str(r1), str(r2)])
result = subprocess.run(quant_cmd, capture_output=True, text=True, check=False)
quant_file = out_dir / "abundance.tsv"
if result.returncode == 0 and quant_file.exists():
quant_records.append({"sample": str(sample_name), "quant_file": str(quant_file)})
else:
failure_text = (result.stderr or result.stdout or "kallisto_quant_failed")[-1000:]
failed_samples[str(sample_name)] = f"returncode={result.returncode}; {failure_text}"
if not quant_records:
return {
"engine": "kallisto",
"status": "failed",
"reason": "no_successful_kallisto_quant",
"failed_samples": failed_samples,
"tx2gene": tx2gene_metrics,
}
with open(sample_quant_file, "w", encoding="utf-8") as handle:
handle.write("sample\tquant_file\n")
for record in quant_records:
handle.write(f"{record['sample']}\t{record['quant_file']}\n")
script = Path("scripts/tximport_abundance.R")
tximport_cmd = [
str(rscript), str(script),
"--samples", str(sample_quant_file),
"--tx2gene", str(tx2gene_file),
"--output", str(final_dir),
"--prefix", prefix,
"--engine", "kallisto",
"--counts-from-abundance", counts_from_abundance,
]
result = subprocess.run(tximport_cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return {
"engine": "kallisto",
"status": "failed",
"reason": "tximport_failed",
"stderr": result.stderr[-2000:],
"cmd": tximport_cmd,
"successful_samples": len(quant_records),
"failed_samples": failed_samples,
"tx2gene": tx2gene_metrics,
}
final_tx2gene = final_dir / f"{prefix}.kallisto.tx2gene.tsv"
if tx2gene_file.exists():
shutil.copy2(tx2gene_file, final_tx2gene)
return {
"engine": "kallisto",
"status": "success" if not failed_samples else "partial_success",
"role": "publication_expression_plots",
"successful_samples": len(quant_records),
"failed_samples": failed_samples,
"bias_correction": {"bias": bias},
"counts_from_abundance": counts_from_abundance,
"transcriptome_fasta": str(transcriptome),
"normalized_transcriptome_fasta": str(normalized_fasta),
"index_file": str(index_file),
"tx2gene": tx2gene_metrics,
"abundance_track_warning": tx2gene_metrics.get("warning", ""),
"artifacts": {
"tpm_matrix": f"final/{prefix}.kallisto.tpm_matrix.txt",
"tximport_counts": f"final/{prefix}.kallisto.tximport_counts.txt",
"length_matrix": f"final/{prefix}.kallisto.length_matrix.txt",
"tx2gene": f"final/{prefix}.kallisto.tx2gene.tsv",
},
}
def sample_name_from_featurecounts_column(sample_path: str) -> str:
"""Convert a featureCounts sample column path to a stable sample name."""
name = Path(str(sample_path)).name
for suffix in (".sorted.bam", ".bam"):
if name.endswith(suffix):
return name[: -len(suffix)]
return Path(name).stem.replace(".sorted", "")
def read_featurecounts_table(count_file: Path) -> Tuple[List[str], List[Dict[str, Any]]]:
"""Read featureCounts output and return sample column names plus row dicts."""
if not count_file.exists():
return [], []
with open(count_file, "r") as f:
lines = f.readlines()
header_idx = None
for i, line in enumerate(lines):
if not line.startswith("#") and line.strip():
header_idx = i
break
if header_idx is None or header_idx >= len(lines) - 1:
return [], []
header = lines[header_idx].rstrip("\n").split("\t")
sample_columns = header[6:]
rows: List[Dict[str, Any]] = []
for line in lines[header_idx + 1:]:
if not line.strip():
continue
parts = line.rstrip("\n").split("\t")
if len(parts) < 7:
continue
try:
length = int(float(parts[5]))
except ValueError:
length = 0
counts: List[int] = []
for value in parts[6:]:
try:
counts.append(int(round(float(value))))
except ValueError:
counts.append(0)
rows.append(
{
"gene_id": parts[0],
"chr": parts[1],
"start": parts[2],
"end": parts[3],
"strand": parts[4],
"length": length,
"counts": counts,
}
)
return sample_columns, rows
def parse_featurecounts_summary(summary_file: Path, sample_names: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]:
"""Parse featureCounts .summary and calculate Assigned rate per sample."""
if not summary_file.exists():
return {}
with open(summary_file, "r") as f:
lines = [line.rstrip("\n").split("\t") for line in f if line.strip()]
if not lines:
return {}
header = lines[0][1:]
samples = sample_names if sample_names and len(sample_names) == len(header) else [
sample_name_from_featurecounts_column(item) for item in header
]
by_sample: Dict[str, Dict[str, Any]] = {
sample: {"featurecounts_status_counts": {}} for sample in samples
}
for row in lines[1:]:
if len(row) < 2:
continue
status = row[0]
for idx, sample in enumerate(samples, start=1):
try:
value = int(float(row[idx])) if idx < len(row) else 0
except ValueError:
value = 0
by_sample[sample]["featurecounts_status_counts"][status] = value
for sample, data in by_sample.items():
status_counts = data["featurecounts_status_counts"]
assigned = int(status_counts.get("Assigned", 0))
total = sum(int(v) for v in status_counts.values())
data["assigned_reads"] = assigned
data["featurecounts_total_reads"] = total
data["featurecounts_assigned_rate"] = (assigned / total * 100.0) if total else 0.0
return by_sample
def read_featurecounts_summary_table(summary_file: Path) -> Tuple[List[str], Dict[str, Dict[str, int]]]:
if not summary_file.exists():
return [], {}
with open(summary_file, "r") as f:
lines = [line.rstrip("\n").split("\t") for line in f if line.strip()]
if not lines:
return [], {}
samples = [sample_name_from_featurecounts_column(item) for item in lines[0][1:]]
by_status: Dict[str, Dict[str, int]] = {}
for row in lines[1:]:
status = row[0]
by_status[status] = {}
for idx, sample in enumerate(samples, start=1):
try:
value = int(float(row[idx])) if idx < len(row) else 0
except ValueError:
value = 0
by_status[status][sample] = value
return samples, by_status
def write_featurecounts_table(sample_names: List[str], rows: List[Dict[str, Any]], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as out:
out.write("Geneid\tChr\tStart\tEnd\tStrand\tLength\t" + "\t".join(sample_names) + "\n")
for row in rows:
out.write(
"\t".join(
[
str(row["gene_id"]),
str(row["chr"]),
str(row["start"]),
str(row["end"]),
str(row["strand"]),
str(row["length"]),
*[str(value) for value in row["counts"]],
]
)
+ "\n"
)
def merge_featurecounts_outputs(
group_outputs: List[Dict[str, Any]],
sample_order: List[str],
merged_output: Path,
) -> None:
gene_rows: Dict[str, Dict[str, Any]] = {}
gene_order: List[str] = []
for group in group_outputs:
sample_columns, rows = read_featurecounts_table(Path(group["output"]))
group_samples = [sample_name_from_featurecounts_column(item) for item in sample_columns]
for row in rows:
gene_id = row["gene_id"]
if gene_id not in gene_rows:
gene_order.append(gene_id)
gene_rows[gene_id] = {
"gene_id": gene_id,
"chr": row["chr"],
"start": row["start"],
"end": row["end"],
"strand": row["strand"],
"length": row["length"],
"counts_by_sample": {sample: 0 for sample in sample_order},
}
for idx, sample in enumerate(group_samples):
if sample in gene_rows[gene_id]["counts_by_sample"] and idx < len(row["counts"]):
gene_rows[gene_id]["counts_by_sample"][sample] = row["counts"][idx]
merged_rows: List[Dict[str, Any]] = []
for gene_id in gene_order:
row = gene_rows[gene_id]
merged_rows.append(
{
"gene_id": row["gene_id"],
"chr": row["chr"],
"start": row["start"],
"end": row["end"],
"strand": row["strand"],
"length": row["length"],
"counts": [row["counts_by_sample"].get(sample, 0) for sample in sample_order],
}
)
write_featurecounts_table(sample_order, merged_rows, merged_output)
def merge_featurecounts_summaries(
group_outputs: List[Dict[str, Any]],
sample_order: List[str],
merged_summary: Path,
) -> None:
status_order: List[str] = []
merged: Dict[str, Dict[str, int]] = {}
for group in group_outputs:
_, by_status = read_featurecounts_summary_table(Path(group["summary"]))
for status, values in by_status.items():
if status not in merged:
status_order.append(status)
merged[status] = {sample: 0 for sample in sample_order}
for sample, value in values.items():
if sample in merged[status]:
merged[status][sample] = value
merged_summary.parent.mkdir(parents=True, exist_ok=True)
with open(merged_summary, "w", encoding="utf-8") as out:
out.write("Status\t" + "\t".join(sample_order) + "\n")
for status in status_order:
out.write(status + "\t" + "\t".join(str(merged[status].get(sample, 0)) for sample in sample_order) + "\n")
def run_featurecounts_by_strandness(
bam_files: List[Path],
sample_names: List[str],
strandness_by_sample: Dict[str, str],
gtf: Path,
count_output: Path,
final_dir: Path,
prefix: str,
cpu: int,
) -> Tuple[Optional[Path], List[Dict[str, Any]]]:
grouped: Dict[str, List[Tuple[str, Path]]] = {}
for sample_name, bam in zip(sample_names, bam_files):
strandness = strandness_by_sample.get(str(sample_name), "non_stranded") or "non_stranded"
grouped.setdefault(strandness, []).append((str(sample_name), bam))
group_results: List[Dict[str, Any]] = []
if len(grouped) <= 1:
strandness = next(iter(grouped.keys()), "non_stranded")
command_log = final_dir / f"{prefix}.featurecounts.command.txt"
annotation = run_featurecounts(
bam_files,
gtf,
count_output,
cpu,
library_strandness=strandness,
command_log=command_log,
)
summary = count_output.with_name(count_output.name + ".summary")
group_results.append(
{
"strandness": strandness,
"featurecounts_s": featurecounts_strandness_value(strandness),
"samples": sample_names,
"output": str(count_output),
"summary": str(summary),
"command_log": str(command_log),
}
)
return annotation, group_results
print(f"[INFO] Mixed library strandness detected; running featureCounts in {len(grouped)} groups.")
counting_annotation: Optional[Path] = None
for strandness, group_items in grouped.items():
group_samples = [item[0] for item in group_items]
group_bams = [item[1] for item in group_items]
safe_strandness = re.sub(r"[^A-Za-z0-9_.-]+", "_", strandness or "non_stranded")
group_output = count_output.with_name(f"featurecounts.{safe_strandness}.txt")
command_log = final_dir / f"{prefix}.featurecounts.{safe_strandness}.command.txt"
annotation = run_featurecounts(
group_bams,
gtf,
group_output,
cpu,
library_strandness=strandness,
command_log=command_log,
)
if annotation is None:
return None, group_results
counting_annotation = annotation
summary = group_output.with_name(group_output.name + ".summary")
group_results.append(
{
"strandness": strandness,
"featurecounts_s": featurecounts_strandness_value(strandness),
"samples": group_samples,
"output": str(group_output),
"summary": str(summary),
"command_log": str(command_log),
}
)
merge_featurecounts_outputs(group_results, sample_names, count_output)
merge_featurecounts_summaries(group_results, sample_names, count_output.with_name(count_output.name + ".summary"))
for group in group_results:
safe_strandness = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(group.get("strandness") or "group"))
final_group_output = final_dir / f"{prefix}.featurecounts.{safe_strandness}.txt"
final_group_summary = final_dir / f"{prefix}.featurecounts.{safe_strandness}.txt.summary"
shutil.copy2(Path(str(group["output"])), final_group_output)
shutil.copy2(Path(str(group["summary"])), final_group_summary)
group["output"] = str(final_group_output)
group["summary"] = str(final_group_summary)
merged_command_log = final_dir / f"{prefix}.featurecounts.command.txt"
with open(merged_command_log, "w", encoding="utf-8") as out:
out.write("Mode: grouped_by_library_strandness\n")
for group in group_results:
out.write(
f"Group {group['strandness']} (-s {group['featurecounts_s']}): "
f"{','.join(group['samples'])}; command_log={group['command_log']}\n"
)
return counting_annotation, group_results
def parse_featurecounts_output(
count_file: Path,
summary_file: Optional[Path] = None,
sample_names: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""解析featureCounts输出"""
stats = {
"total_genes": 0,
"total_transcripts": 0,
"expressed_genes": 0,
"samples": {},
"expression_matrix_status": "not_generated",
"gene_length_source": "featureCounts Length column",
}
sample_columns, rows = read_featurecounts_table(count_file)
if not rows:
return stats
samples = sample_names if sample_names and len(sample_names) == len(sample_columns) else [
sample_name_from_featurecounts_column(item) for item in sample_columns
]
stats["total_genes"] = len(rows)
stats["expressed_genes"] = sum(1 for row in rows if any(count > 0 for count in row["counts"]))
summary_stats = parse_featurecounts_summary(summary_file, samples) if summary_file else {}
for idx, sample_name in enumerate(samples):
assigned_reads = sum(row["counts"][idx] for row in rows if idx < len(row["counts"]))
stats["samples"][sample_name] = {
"assigned_reads": assigned_reads,
"unassigned_reads": 0,
}
if sample_name in summary_stats:
stats["samples"][sample_name].update(summary_stats[sample_name])
return stats
def generate_expression_matrices(
count_file: Path,
output_dir: Path,
prefix: str,
sample_names: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Generate count, FPKM, TPM, gene length, and normalization matrices.
FPKM and TPM are calculated from the featureCounts Length column and raw
assigned fragment counts. They are intended for expression visualization;
differential expression should continue to use raw count_matrix.
"""
sample_columns, rows = read_featurecounts_table(count_file)
if not rows:
raise RuntimeError(f"Cannot parse featureCounts output: {count_file}")
samples = sample_names if sample_names and len(sample_names) == len(sample_columns) else [
sample_name_from_featurecounts_column(item) for item in sample_columns
]
bad_length_genes = [row["gene_id"] for row in rows if row["length"] <= 0]
usable_rows = [row for row in rows if row["length"] > 0]
if not usable_rows:
raise RuntimeError("featureCounts output has no genes with positive Length; cannot calculate FPKM/TPM")
count_matrix = output_dir / f"{prefix}.count_matrix.txt"
fpkm_matrix = output_dir / f"{prefix}.fpkm_matrix.txt"
tpm_matrix = output_dir / f"{prefix}.tpm_matrix.txt"
gene_lengths_file = output_dir / f"{prefix}.gene_lengths.txt"
norm_info_file = output_dir / f"{prefix}.normalization_info.txt"
assigned_totals = [
sum(row["counts"][idx] for row in rows if idx < len(row["counts"]))
for idx in range(len(samples))
]
rpk_sums: List[float] = []
for idx in range(len(samples)):
rpk_sum = 0.0
for row in usable_rows:
if idx < len(row["counts"]):
rpk_sum += row["counts"][idx] / (row["length"] / 1000.0)
rpk_sums.append(rpk_sum)
with open(count_matrix, "w") as out:
out.write("gene_id\t" + "\t".join(samples) + "\n")
for row in rows:
out.write(row["gene_id"] + "\t" + "\t".join(str(v) for v in row["counts"]) + "\n")
with open(gene_lengths_file, "w") as out:
out.write("gene_id\tlength\n")
for row in rows:
out.write(f"{row['gene_id']}\t{row['length']}\n")
with open(fpkm_matrix, "w") as fpkm_out, open(tpm_matrix, "w") as tpm_out:
header = "gene_id\t" + "\t".join(samples) + "\n"
fpkm_out.write(header)
tpm_out.write(header)
for row in rows:
fpkm_values: List[str] = []
tpm_values: List[str] = []
length_kb = row["length"] / 1000.0 if row["length"] > 0 else 0.0
for idx, count in enumerate(row["counts"]):
if length_kb <= 0:
fpkm = 0.0
tpm = 0.0
else:
assigned_million = assigned_totals[idx] / 1_000_000.0 if assigned_totals[idx] else 0.0
rpk = count / length_kb
fpkm = (rpk / assigned_million) if assigned_million else 0.0
tpm = (rpk / rpk_sums[idx] * 1_000_000.0) if rpk_sums[idx] else 0.0
fpkm_values.append(f"{fpkm:.6f}")
tpm_values.append(f"{tpm:.6f}")
fpkm_out.write(row["gene_id"] + "\t" + "\t".join(fpkm_values) + "\n")
tpm_out.write(row["gene_id"] + "\t" + "\t".join(tpm_values) + "\n")
with open(norm_info_file, "w") as out:
out.write("sample\tassigned_fragments\trpk_sum\ttpm_sum\n")
for idx, sample in enumerate(samples):
out.write(f"{sample}\t{assigned_totals[idx]}\t{rpk_sums[idx]:.6f}\t1000000\n")
return {
"count_matrix": count_matrix,
"fpkm_matrix": fpkm_matrix,
"tpm_matrix": tpm_matrix,
"gene_lengths_file": gene_lengths_file,
"normalization_info_file": norm_info_file,
"samples": samples,
"expression_matrix_status": "computed_fpkm_tpm",
"gene_length_source": "featureCounts Length column",
"library_size_source": "featureCounts assigned fragments",
"bad_length_genes": len(bad_length_genes),
"gene_length_match_rate": len(usable_rows) / len(rows) * 100.0,
}
def generate_report(
output_dir: Path,
stats: Dict[str, Any],
versions: Dict[str, str],
start_time: datetime,
end_time: datetime,
prefix: str = "",
abundance_track: Optional[Dict[str, Any]] = None,
sample_config: Optional[str] = None,
library_strandness: Optional[Dict[str, Any]] = None,
) -> Path:
"""生成定量报告"""
report_name = f"{prefix}.rnaseq_quant_report.md" if prefix else "rnaseq_quant_report.md"
report_file = output_dir / report_name
elapsed = (end_time - start_time).total_seconds()
expression_status = stats.get("expression_matrix_status", "unknown")
content = f"""# RNA-Seq Quantification Report
## Summary
- **Analysis Date**: {end_time.strftime("%Y-%m-%d %H:%M:%S")}
- **Total Genes**: {stats.get('total_genes', 'N/A')}
- **Expressed Genes**: {stats.get('expressed_genes', 'N/A')}
- **Samples Processed**: {len(stats.get('samples', {}))}
- **Expression Matrix Status**: {expression_status}
- **Total Runtime**: {elapsed:.1f} seconds
- **Library Strandness**: {", ".join((library_strandness or {}).get("library_strandness_values", [])) or "not_recorded"}
## Tool Versions
"""
for tool, version in versions.items():
content += f"- **{tool}**: {version}\n"
content += "\n## Sample Assignment Statistics\n\n"
content += "| Sample | Assigned Fragments | featureCounts Total | Assigned Rate |\n"
content += "|--------|--------------------|---------------------|---------------|\n"
for sample, row in (stats.get("samples") or {}).items():
assigned = row.get("assigned_reads", "N/A")
total = row.get("featurecounts_total_reads", "N/A")
rate = row.get("featurecounts_assigned_rate")
rate_text = f"{rate:.2f}%" if isinstance(rate, (int, float)) else "N/A"
content += f"| {sample} | {assigned} | {total} | {rate_text} |\n"
content += "\n## Output Files\n\n"
content += f"- Count matrix: `{prefix}.count_matrix.txt` (raw featureCounts counts; DESeq2/edgeR input)\n"
content += f"- FPKM matrix: `{prefix}.fpkm_matrix.txt` (featureCounts-derived fallback for expression visualization)\n"
content += f"- TPM matrix: `{prefix}.tpm_matrix.txt` (featureCounts-derived fallback; not the publication-plot default when transcript-aware TPM exists)\n"
content += f"- Gene lengths: `{prefix}.gene_lengths.txt`\n"
content += f"- Normalization info: `{prefix}.normalization_info.txt`\n"
content += f"- featureCounts summary: `{prefix}.featurecounts.txt.summary`\n"
if abundance_track and abundance_track.get("status") in {"success", "partial_success"}:
engine = abundance_track.get("engine", "salmon")
content += f"- Transcript-aware TPM matrix: `{prefix}.{engine}.tpm_matrix.txt` (preferred for publication expression plots)\n"
content += f"- tximport counts: `{prefix}.{engine}.tximport_counts.txt` (not used for DE by default)\n"
content += f"- tximport effective lengths: `{prefix}.{engine}.length_matrix.txt`\n"
if abundance_track:
content += "\n## Quantification Tracks\n\n"
content += "| Track | Engine | Status | Role |\n"
content += "|---|---|---|---|\n"
content += "| count_track | featureCounts | success | DE primary raw counts |\n"
content += f"| abundance_track | {abundance_track.get('engine', 'salmon')} | {abundance_track.get('status', 'unknown')} | publication expression plots |\n"
if abundance_track.get("reason"):
content += f"\nAbundance track note: `{abundance_track.get('reason')}`.\n"
matrix_bridge_qc = stats.get("matrix_bridge_qc") or {}
if matrix_bridge_qc:
content += "\n## Matrix Bridge QC\n\n"
content += "| Check | Value |\n"
content += "|---|---|\n"
content += f"| Status | {matrix_bridge_qc.get('status', 'unknown')} |\n"
content += f"| Recommended expression TPM | {matrix_bridge_qc.get('recommended_expression_tpm_source', 'unknown')} |\n"
content += f"| Sample columns match | {matrix_bridge_qc.get('sample_columns_match', 'unknown')} |\n"
content += f"| Gene overlap vs featureCounts | {matrix_bridge_qc.get('gene_overlap_vs_featurecounts_pct', 'NA')}% |\n"
content += f"| TPM sums within tolerance | {matrix_bridge_qc.get('tpm_column_sums_within_tolerance', 'unknown')} |\n"
if "duplicate_transcripts_removed" in matrix_bridge_qc:
content += f"| Salmon duplicate transcripts removed | {matrix_bridge_qc.get('duplicate_transcripts_removed', 0)} |\n"
if matrix_bridge_qc.get("failure_reasons"):
content += f"| Fallback reasons | {', '.join(matrix_bridge_qc.get('failure_reasons', []))} |\n"
content += "\n## Methods\n\n"
content += "Raw gene counts were generated by featureCounts. FPKM and TPM were calculated from the featureCounts `Length` column and raw assigned fragment counts. "
content += "FPKM uses each sample's assigned fragments as the library size denominator; TPM is calculated from reads per kilobase (RPK) and scaled so each sample sums to 1e6. "
content += "FPKM/TPM are intended for expression visualization and clustering; differential expression should use the raw count matrix.\n"
if abundance_track and abundance_track.get("status") in {"success", "partial_success"}:
content += "A second transcript-aware abundance track was generated with Salmon/Kallisto and tximport. Its TPM matrix is the preferred matrix for publication-style expression plots only when Matrix Bridge QC passes; DE remains on featureCounts raw counts unless explicitly overridden.\n"
if stats.get("bad_length_genes"):
content += f"\n## Notes\n\n- {stats.get('bad_length_genes')} genes had non-positive featureCounts Length and were assigned 0 FPKM/TPM.\n"
with open(report_file, 'w') as f:
f.write(content)
return report_file
def add_artifact_to_manifest(manifest: Dict, 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
}
def generate_manifest(
output_dir: Path,
stats: 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,
quant_mode: str = "dual",
abundance_engine: str = "salmon",
abundance_track: Optional[Dict[str, Any]] = None,
sample_config: Optional[str] = None,
library_strandness: Optional[Dict[str, Any]] = None,
featurecounts_groups: Optional[List[Dict[str, Any]]] = None,
) -> Path:
"""生成manifest文件"""
manifest_name = f"{prefix}.rnaseq_quant.manifest.json" if prefix else "rnaseq_quant.manifest.json"
manifest_file = output_dir / manifest_name
groups = featurecounts_groups or []
grouped_featurecounts = len(groups) > 1
if grouped_featurecounts:
group_param_text = "; ".join(
f"{group.get('strandness')}=-s {group.get('featurecounts_s')} ({len(group.get('samples', []))} samples)"
for group in groups
)
featurecounts_param_text = f"-t exon -g gene_id -p --countReadPairs -B -C; grouped_by_library_strandness: {group_param_text}"
else:
default_strandness = (
groups[0].get("strandness")
if groups
else ((library_strandness or {}).get("library_strandness_values", ["non_stranded"])[0]
if (library_strandness or {}).get("library_strandness_values")
else "non_stranded")
)
featurecounts_param_text = (
f"-T {threads_per_sample} -t exon -g gene_id -p --countReadPairs -B -C "
f"-s {featurecounts_strandness_value(default_strandness)}"
)
matrix_bridge_qc = stats.get("matrix_bridge_qc") or {}
manifest = {
"manifest_version": "1.0",
"stage": "rnaseq_quant",
"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_genes": stats.get("total_genes", 0),
"total_transcripts": stats.get("total_transcripts", 0),
"expressed_genes": stats.get("expressed_genes", 0),
"samples_processed": len(stats.get("samples", {})),
"samples": stats.get("samples", {}),
"expression_matrix_status": stats.get("expression_matrix_status", "unknown"),
"abundance_track_status": (abundance_track or {}).get("status", "not_requested"),
"gene_length_source": stats.get("gene_length_source", "featureCounts Length column"),
"library_size_source": stats.get("library_size_source", "featureCounts assigned fragments"),
"gene_length_match_rate": stats.get("gene_length_match_rate"),
"bad_length_genes": stats.get("bad_length_genes", 0),
"matrix_bridge_qc": matrix_bridge_qc,
},
"parameters": {
"cpu": cpu,
"sample_parallel_jobs": sample_parallel_jobs,
"threads_per_sample": threads_per_sample,
"prefix": prefix,
"stringtie_params": f"-p {threads_per_sample}",
"featurecounts_params": featurecounts_param_text,
"featurecounts_strandness": ",".join((library_strandness or {}).get("library_strandness_values", [])),
"featurecounts_grouped": grouped_featurecounts,
"featurecounts_groups": groups,
"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", ""),
"fpkm_formula": "count / (gene_length_kb * assigned_fragments_million)",
"tpm_formula": "(count / gene_length_kb) / sum(count / gene_length_kb) * 1e6",
"expression_matrix_usage": "FPKM/TPM are for visualization; raw count_matrix is the DE input",
"quant_mode": quant_mode,
"abundance_engine": abundance_engine,
"kallisto_bias": (abundance_track or {}).get("bias_correction"),
},
"quantification_tracks": {
"count_track": {
"engine": "featureCounts",
"status": "success",
"role": "de_primary_raw_counts",
"count_matrix": f"final/{prefix}.count_matrix.txt",
},
"abundance_track": abundance_track or {
"engine": abundance_engine,
"status": "not_requested" if quant_mode == "count_only" else "skipped",
},
},
"matrix_policy": {
"de_default": "featurecounts_raw_counts",
"expression_plot_default": matrix_bridge_qc.get("recommended_expression_tpm_source", "salmon_tximport_tpm_if_bridge_qc_passes_else_featurecounts_tpm"),
"matrix_bridge_qc": f"final/{prefix}.rnaseq_quant.matrix_bridge_qc.tsv",
"scope_policy": "global_quantification_scope_agnostic; subset matrices after quantification",
},
"artifacts": {},
"tools": versions
}
context = build_rnaseq_context(
prefix=prefix,
sample_config=sample_config,
count_matrix=output_dir / f"{prefix}.count_matrix.txt",
foreground_mode="global",
stage="rnaseq_quant",
)
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)
count_matrix = output_dir / f"{prefix}.count_matrix.txt"
fpkm_matrix = output_dir / f"{prefix}.fpkm_matrix.txt"
tpm_matrix = output_dir / f"{prefix}.tpm_matrix.txt"
report_file = output_dir / f"{prefix}.rnaseq_quant_report.md"
gene_lengths_file = output_dir / f"{prefix}.gene_lengths.txt"
norm_info_file = output_dir / f"{prefix}.normalization_info.txt"
featurecounts_summary = output_dir / f"{prefix}.featurecounts.txt.summary"
featurecounts_command = output_dir / f"{prefix}.featurecounts.command.txt"
matrix_bridge_qc_file = output_dir / f"{prefix}.rnaseq_quant.matrix_bridge_qc.tsv"
add_artifact_to_manifest(manifest, "count_matrix", count_matrix, "tsv", format="tsv")
add_artifact_to_manifest(manifest, "fpkm_matrix", fpkm_matrix, "tsv", format="tsv", status="computed_fpkm_tpm")
add_artifact_to_manifest(manifest, "tpm_matrix", tpm_matrix, "tsv", format="tsv", status="computed_fpkm_tpm")
add_artifact_to_manifest(manifest, "report", report_file, "markdown", format="md")
add_artifact_to_manifest(manifest, "gene_lengths", gene_lengths_file, "tsv", format="tsv")
add_artifact_to_manifest(manifest, "normalization_info", norm_info_file, "tsv", format="tsv")
add_artifact_to_manifest(manifest, "featurecounts_summary", featurecounts_summary, "tsv", format="tsv")
add_artifact_to_manifest(manifest, "featurecounts_command", featurecounts_command, "text", format="txt")
add_artifact_to_manifest(manifest, "matrix_bridge_qc", matrix_bridge_qc_file, "tsv", format="tsv", status=matrix_bridge_qc.get("status", "unknown"))
if grouped_featurecounts:
for idx, group in enumerate(groups, start=1):
suffix = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(group.get("strandness") or f"group{idx}"))
add_artifact_to_manifest(
manifest,
f"featurecounts_group_{suffix}",
Path(str(group.get("output", ""))),
"tsv",
format="featurecounts",
samples=group.get("samples", []),
strandness=group.get("strandness", ""),
featurecounts_s=group.get("featurecounts_s", ""),
)
add_artifact_to_manifest(
manifest,
f"featurecounts_group_{suffix}_summary",
Path(str(group.get("summary", ""))),
"tsv",
format="featurecounts_summary",
samples=group.get("samples", []),
strandness=group.get("strandness", ""),
)
add_artifact_to_manifest(
manifest,
f"featurecounts_group_{suffix}_command",
Path(str(group.get("command_log", ""))),
"text",
format="txt",
samples=group.get("samples", []),
strandness=group.get("strandness", ""),
)
for artifact_name, suffix in {
"salmon_tpm_matrix": "salmon.tpm_matrix.txt",
"salmon_tximport_counts": "salmon.tximport_counts.txt",
"salmon_length_matrix": "salmon.length_matrix.txt",
"salmon_tx2gene": "salmon.tx2gene.tsv",
"salmon_tximport_summary": "salmon.tximport_summary.tsv",
"kallisto_tpm_matrix": "kallisto.tpm_matrix.txt",
"kallisto_tximport_counts": "kallisto.tximport_counts.txt",
"kallisto_length_matrix": "kallisto.length_matrix.txt",
"kallisto_tx2gene": "kallisto.tx2gene.tsv",
"kallisto_tximport_summary": "kallisto.tximport_summary.tsv",
}.items():
add_artifact_to_manifest(manifest, artifact_name, output_dir / f"{prefix}.{suffix}", "tsv", format="tsv", status=(abundance_track or {}).get("status", "unknown"))
multiqc_name = f"{prefix}.rnaseq_quant_multiqc_report.html" if prefix else "rnaseq_quant_multiqc_report.html"
multiqc_file = output_dir / multiqc_name
add_artifact_to_manifest(manifest, "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["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 Quantification")
parser.add_argument("-i", "--input", required=True, help="Input directory with BAM files")
parser.add_argument("-g", "--gtf", required=True, help="Reference genome GTF annotation")
parser.add_argument("-o", "--output", required=True, help="Output directory")
parser.add_argument("-p", "--prefix", default="rnaseq", help="Output prefix")
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 (reserved; default serial)")
parser.add_argument("--threads-per-sample", type=int, help="Threads used by each per-sample tool invocation")
parser.add_argument("--sample-config", help="Sample config YAML for sample list")
parser.add_argument("--quant-mode", default="dual", choices=["dual", "count_only"], help="Run count track only or count + transcript-aware abundance track")
parser.add_argument("--abundance-engine", default="auto", choices=["auto", "salmon", "kallisto", "none"], help="Transcript-aware abundance engine; auto tries Salmon then kallisto fallback.")
parser.add_argument("--transcriptome-fasta", help="Transcript/CDS FASTA for Salmon index; auto-detected if omitted")
parser.add_argument("--tx2gene", help="Optional transcript-to-gene TSV with transcript_id and gene_id columns")
parser.add_argument("--salmon-libtype", default="A", help="Salmon library type; A enables automatic inference")
parser.add_argument("--tximport-counts-from-abundance", default="no", choices=["no", "scaledTPM", "lengthScaledTPM", "dtuScaledTPM"], help="tximport countsFromAbundance setting")
parser.add_argument("--kallisto-bias", action="store_true", help="Enable kallisto --bias. Default is off because it is optional and can be unstable in some conda builds.")
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)
gtf = Path(args.gtf)
context_for_prefix = resolve_rnaseq_project_context(
input_dir=Path(args.sample_config).parent if args.sample_config else input_dir,
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 '<empty>'} "
f"(source={context_for_prefix.get('source', '')})"
)
args.prefix = resolved_prefix
# 创建输出目录
# output_dir 是 workspace/{job_id}/rnaseq_quant (由 pipeline_backend.py 传入)
# final_dir 是 workspace/{job_id}/final (契约要求最终产物必须放在这里)
output_dir.mkdir(parents=True, exist_ok=True)
# 从 output_dir 推导 workspace_dir 和 final_dir
# output_dir = workspace/{job_id}/rnaseq_quant
# workspace_dir = workspace/{job_id}
# final_dir = workspace/{job_id}/final
workspace_dir = output_dir.parent
final_dir = workspace_dir / "final"
final_dir.mkdir(exist_ok=True)
# 中间文件放在 rnaseq_quant/analysis/ 下(契约要求)
quant_dir = output_dir / "analysis"
quant_dir.mkdir(exist_ok=True)
# 确定样本列表
samples = {}
if args.sample_config:
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:
# 从输入目录自动检测BAM文件
for f in sorted(input_dir.glob("*.sorted.bam")):
sample_name = f.stem.replace(".sorted", "")
samples[sample_name] = str(f)
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_values = strandness_info.get("library_strandness_values", [])
featurecounts_strandness = strandness_values[0] if strandness_values else ""
print(f"Library strandness: {strandness_info.get('library_strandness_by_sample', {})}")
if len(strandness_values) > 1:
print(f"[INFO] Mixed library strandness will be counted by featureCounts groups: {strandness_values}")
# 获取工具版本
versions = get_tool_versions()
print(f"Tool versions: {versions}")
bam_files = []
sample_names = []
# 处理每个样本
for sample_name, sample_value in samples.items():
bam = resolve_sample_bam(input_dir, str(sample_name), sample_value)
if bam is None:
print(f"Warning: Unknown sample config format for sample {sample_name}, skipping...")
continue
if not bam.exists():
print(f"Warning: BAM file not found for sample {sample_name}, skipping...")
continue
print(f"Quantifying sample: {sample_name}")
# 运行StringTie
output_gtf = quant_dir / f"{sample_name}_stringtie.gtf"
output_cov = quant_dir / f"{sample_name}_coverage.txt"
run_stringtie(bam, gtf, output_gtf, output_cov, threads_per_sample)
bam_files.append(bam)
sample_names.append(sample_name)
# 运行featureCounts
count_output = quant_dir / "featurecounts.txt"
final_command_log = final_dir / f"{args.prefix}.featurecounts.command.txt"
if not bam_files:
raise RuntimeError("No valid BAM files were found; cannot run featureCounts")
counting_gtf, featurecounts_groups = run_featurecounts_by_strandness(
bam_files=bam_files,
sample_names=sample_names,
strandness_by_sample=strandness_info.get("library_strandness_by_sample", {}),
gtf=gtf,
count_output=count_output,
final_dir=final_dir,
prefix=args.prefix,
cpu=threads_per_sample,
)
if counting_gtf is None:
raise RuntimeError("featureCounts failed; cannot generate expression matrices")
summary_output = count_output.with_name(count_output.name + ".summary")
final_summary = final_dir / f"{args.prefix}.featurecounts.txt.summary"
if summary_output.exists():
shutil.copy2(summary_output, final_summary)
print("[INFO] Calculating gene-level FPKM/TPM from featureCounts Length and counts...")
matrix_result = generate_expression_matrices(
count_file=count_output,
output_dir=final_dir,
prefix=args.prefix,
sample_names=sample_names,
)
# 统计
stats = parse_featurecounts_output(count_output, final_summary, matrix_result.get("samples"))
for key in (
"expression_matrix_status",
"gene_length_source",
"library_size_source",
"gene_length_match_rate",
"bad_length_genes",
):
stats[key] = matrix_result.get(key)
abundance_track = {"engine": args.abundance_engine, "status": "not_requested"}
transcriptome_arg = Path(args.transcriptome_fasta) if args.transcriptome_fasta else None
tx2gene_arg = Path(args.tx2gene) if args.tx2gene else None
if args.quant_mode == "dual" and args.abundance_engine in {"auto", "salmon"}:
print("[INFO] Running transcript-aware abundance track with Salmon + tximport...")
abundance_track = run_salmon_abundance_track(
samples=samples, input_dir=input_dir, workspace_dir=workspace_dir, final_dir=final_dir,
analysis_dir=quant_dir, prefix=args.prefix, gtf=gtf, transcriptome_fasta=transcriptome_arg,
tx2gene_override=tx2gene_arg, threads=threads_per_sample, libtype=args.salmon_libtype,
counts_from_abundance=args.tximport_counts_from_abundance,
)
if args.abundance_engine == "auto" and abundance_track.get("status") not in {"success", "partial_success"}:
salmon_failure = abundance_track
print(f"[INFO] Salmon abundance track did not complete ({salmon_failure.get('reason', salmon_failure.get('status'))}); trying kallisto + tximport...")
abundance_track = run_kallisto_abundance_track(
samples=samples, input_dir=input_dir, workspace_dir=workspace_dir, final_dir=final_dir,
analysis_dir=quant_dir, prefix=args.prefix, gtf=gtf, transcriptome_fasta=transcriptome_arg,
tx2gene_override=tx2gene_arg, threads=threads_per_sample,
counts_from_abundance=args.tximport_counts_from_abundance, bias=args.kallisto_bias,
)
abundance_track["fallback_from"] = salmon_failure
print(f"[INFO] Abundance track status: {abundance_track.get('status')} ({abundance_track.get('reason', 'ok')})")
elif args.quant_mode == "dual" and args.abundance_engine == "kallisto":
print("[INFO] Running transcript-aware abundance track with kallisto + tximport...")
abundance_track = run_kallisto_abundance_track(
samples=samples, input_dir=input_dir, workspace_dir=workspace_dir, final_dir=final_dir,
analysis_dir=quant_dir, prefix=args.prefix, gtf=gtf, transcriptome_fasta=transcriptome_arg,
tx2gene_override=tx2gene_arg, threads=threads_per_sample,
counts_from_abundance=args.tximport_counts_from_abundance,
)
print(f"[INFO] Abundance track status: {abundance_track.get('status')} ({abundance_track.get('reason', 'ok')})")
elif args.quant_mode == "count_only" or args.abundance_engine == "none":
abundance_track = {"engine": args.abundance_engine, "status": "not_requested", "reason": "disabled_by_cli"}
bridge_engine = str(abundance_track.get("engine") or args.abundance_engine or "salmon")
bridge_tpm = final_dir / f"{args.prefix}.{bridge_engine}.tpm_matrix.txt"
bridge_tx2gene = final_dir / f"{args.prefix}.{bridge_engine}.tx2gene.tsv"
if abundance_track.get("status") not in {"success", "partial_success"} or not bridge_tpm.exists():
bridge_tpm_arg: Optional[Path] = None
else:
bridge_tpm_arg = bridge_tpm
bridge_index_dir = Path(str(abundance_track.get("index_dir", ""))) if abundance_track.get("index_dir") else None
matrix_bridge_qc = compute_matrix_bridge_qc(
count_matrix=final_dir / f"{args.prefix}.count_matrix.txt",
abundance_tpm=bridge_tpm_arg,
tx2gene=bridge_tx2gene if bridge_tx2gene.exists() else None,
engine=bridge_engine,
salmon_index_dir=bridge_index_dir if bridge_engine == "salmon" else None,
tx2gene_metrics=abundance_track.get("tx2gene") if isinstance(abundance_track.get("tx2gene"), dict) else None,
output_path=final_dir / f"{args.prefix}.rnaseq_quant.matrix_bridge_qc.tsv",
)
stats["matrix_bridge_qc"] = matrix_bridge_qc
abundance_track["matrix_bridge_qc"] = matrix_bridge_qc
# 生成 MultiQC 报告
multiqc_name = f"{args.prefix}.rnaseq_quant_multiqc_report.html" if args.prefix else "rnaseq_quant_multiqc_report.html"
multiqc_report = run_multiqc([quant_dir, final_dir], final_dir, multiqc_name)
if multiqc_report:
print(f"MultiQC report: {multiqc_report}")
# 生成报告
end_time = datetime.now()
generate_report(
final_dir,
stats,
versions,
start_time,
end_time,
args.prefix,
abundance_track=abundance_track,
library_strandness=strandness_info,
)
# 生成manifest
# 注意:按照rnaseq_qc的实际行为(虽然与契约不符)
# manifest文件放在final_dir,workspace字段也指向final_dir
cmd = ["python", __file__, "-i", str(input_dir), "-g", str(gtf),
"-o", str(output_dir), "-p", args.prefix, "-j", args.job_id, "-r", args.run_id,
"--quant-mode", args.quant_mode, "--abundance-engine", args.abundance_engine]
generate_manifest(
final_dir, # manifest文件放在final_dir
stats,
versions,
start_time,
end_time,
cmd,
input_dir,
quant_dir, # 这个参数在generate_manifest中被忽略,实际使用output_dir(final_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,
quant_mode=args.quant_mode,
abundance_engine=args.abundance_engine,
abundance_track=abundance_track,
sample_config=args.sample_config,
library_strandness=strandness_info,
featurecounts_groups=featurecounts_groups,
)
print(f"RNA-QUAN completed. Results in {output_dir}")
if __name__ == "__main__":
main()