242 lines
8.1 KiB
Python
242 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""
|
||
|
|
KaKs Analysis Utilities
|
||
|
|
Ka/Ks分析工具函数
|
||
|
|
"""
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Dict, List, Optional, Tuple
|
||
|
|
|
||
|
|
from Bio import SeqIO
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
def compute_checksum(filepath: Path) -> str:
|
||
|
|
"""计算文件SHA256校验值"""
|
||
|
|
sha256 = hashlib.sha256()
|
||
|
|
with open(filepath, "rb") as f:
|
||
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
||
|
|
sha256.update(chunk)
|
||
|
|
return sha256.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def check_tool(tool_name: str) -> Optional[str]:
|
||
|
|
"""检查工具是否可用"""
|
||
|
|
path = shutil.which(tool_name)
|
||
|
|
if path:
|
||
|
|
return path
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def get_tool_version(tool_name: str) -> str:
|
||
|
|
"""获取工具版本信息"""
|
||
|
|
try:
|
||
|
|
if tool_name == "muscle":
|
||
|
|
result = subprocess.run(
|
||
|
|
["muscle", "-version"],
|
||
|
|
capture_output=True, text=True, timeout=5
|
||
|
|
)
|
||
|
|
return result.stdout.strip().split('\n')[0] if result.stdout else "unknown"
|
||
|
|
elif tool_name == "yn00" or tool_name == "codeml":
|
||
|
|
result = subprocess.run(
|
||
|
|
[tool_name],
|
||
|
|
capture_output=True, text=True, timeout=5
|
||
|
|
)
|
||
|
|
output = result.stderr.strip() or result.stdout.strip()
|
||
|
|
lines = output.split('\n')
|
||
|
|
for line in lines:
|
||
|
|
if "version" in line.lower() or "paml" in line.lower():
|
||
|
|
return line.strip()
|
||
|
|
return "PAML (version unknown)"
|
||
|
|
elif tool_name == "pal2nal":
|
||
|
|
return "pal2nal v14.1"
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning(f"Failed to get {tool_name} version: {e}")
|
||
|
|
return "not_found"
|
||
|
|
|
||
|
|
|
||
|
|
def parse_cds_fasta(fasta_path: Path) -> Dict[str, str]:
|
||
|
|
"""解析CDS FASTA文件,返回基因ID到序列的字典"""
|
||
|
|
sequences = {}
|
||
|
|
for record in SeqIO.parse(fasta_path, "fasta"):
|
||
|
|
gene_id = record.id
|
||
|
|
sequences[gene_id] = str(record.seq)
|
||
|
|
return sequences
|
||
|
|
|
||
|
|
|
||
|
|
def parse_ortholog_pairs(pairs_file: Path) -> List[Tuple[str, str]]:
|
||
|
|
"""解析同源基因对文件,格式: gene1\tgene2"""
|
||
|
|
pairs = []
|
||
|
|
with open(pairs_file, 'r') as f:
|
||
|
|
for line in f:
|
||
|
|
line = line.strip()
|
||
|
|
if not line or line.startswith('#'):
|
||
|
|
continue
|
||
|
|
parts = line.split('\t')
|
||
|
|
if len(parts) >= 2:
|
||
|
|
gene1 = parts[0].strip()
|
||
|
|
gene2 = parts[1].strip()
|
||
|
|
pairs.append((gene1, gene2))
|
||
|
|
return pairs
|
||
|
|
|
||
|
|
|
||
|
|
def extract_sequences(cds_dict: Dict[str, str], gene_ids: List[str], output_path: Path) -> int:
|
||
|
|
"""从CDS字典中提取指定基因的序列并写入文件"""
|
||
|
|
count = 0
|
||
|
|
with open(output_path, 'w') as f:
|
||
|
|
for gene_id in gene_ids:
|
||
|
|
if gene_id in cds_dict:
|
||
|
|
seq = cds_dict[gene_id]
|
||
|
|
f.write(f">{gene_id}\n")
|
||
|
|
for i in range(0, len(seq), 60):
|
||
|
|
f.write(seq[i:i+60] + '\n')
|
||
|
|
count += 1
|
||
|
|
else:
|
||
|
|
logger.warning(f"Gene {gene_id} not found in CDS file")
|
||
|
|
return count
|
||
|
|
|
||
|
|
|
||
|
|
def run_muscle_alignment(input_fasta: Path, output_aln: Path, maxiters: int = 2, cpu: int = 8) -> bool:
|
||
|
|
"""运行MUSCLE进行蛋白序列比对"""
|
||
|
|
muscle_path = check_tool("muscle")
|
||
|
|
if not muscle_path:
|
||
|
|
logger.error("muscle not found in PATH")
|
||
|
|
return False
|
||
|
|
|
||
|
|
try:
|
||
|
|
cmd = [
|
||
|
|
"muscle", "-align", str(input_fasta), "-output", str(output_aln),
|
||
|
|
"-maxiters", str(maxiters), "-threads", str(cpu)
|
||
|
|
]
|
||
|
|
logger.info(f"Running MUSCLE: {' '.join(cmd)}")
|
||
|
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||
|
|
return output_aln.exists() and output_aln.stat().st_size > 0
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"MUSCLE error: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def run_pal2nal(protein_aln: Path, cds_fasta: Path, output_codon: Path, output_format: str = "fasta") -> bool:
|
||
|
|
"""运行pal2nal将蛋白比对转换为密码子比对"""
|
||
|
|
pal2nal_path = check_tool("pal2nal.pl") or check_tool("pal2nal")
|
||
|
|
if not pal2nal_path:
|
||
|
|
logger.error("pal2nal.pl not found in PATH")
|
||
|
|
return False
|
||
|
|
|
||
|
|
try:
|
||
|
|
cmd = [
|
||
|
|
"perl", str(pal2nal_path), str(protein_aln), str(cds_fasta),
|
||
|
|
"-output", output_format, "-nogap", "-nomismatch"
|
||
|
|
]
|
||
|
|
logger.info(f"Running pal2nal: {' '.join(cmd)}")
|
||
|
|
with open(output_codon, 'w') as out_f:
|
||
|
|
subprocess.run(cmd, check=True, stdout=out_f, stderr=subprocess.PIPE, text=True)
|
||
|
|
return output_codon.exists() and output_codon.stat().st_size > 0
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"pal2nal error: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def create_yn00_ctl(seqfile: Path, outfile: Path, genetic_code: int = 0) -> Path:
|
||
|
|
"""创建yn00的控制文件"""
|
||
|
|
ctl_path = seqfile.parent / "yn00.ctl"
|
||
|
|
ctl_content = f"""seqfile = {seqfile}
|
||
|
|
outfile = {outfile}
|
||
|
|
noisy = 9
|
||
|
|
verbose = 1
|
||
|
|
icode = {genetic_code}
|
||
|
|
weighting = 0
|
||
|
|
commonf3x4 = 0
|
||
|
|
"""
|
||
|
|
with open(ctl_path, 'w') as f:
|
||
|
|
f.write(ctl_content)
|
||
|
|
return ctl_path
|
||
|
|
|
||
|
|
|
||
|
|
def run_yn00(codon_aln: Path, output_dir: Path, genetic_code: int = 0) -> Optional[Path]:
|
||
|
|
"""运行yn00计算Ka/Ks"""
|
||
|
|
yn00_path = check_tool("yn00")
|
||
|
|
if not yn00_path:
|
||
|
|
logger.error("yn00 not found in PATH")
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
output_file = output_dir / "yn00.out"
|
||
|
|
ctl_file = create_yn00_ctl(codon_aln, output_file, genetic_code)
|
||
|
|
cmd = [yn00_path, str(ctl_file)]
|
||
|
|
logger.info(f"Running yn00: {' '.join(cmd)}")
|
||
|
|
subprocess.run(cmd, cwd=output_dir, check=True, capture_output=True, text=True)
|
||
|
|
return output_file if output_file.exists() and output_file.stat().st_size > 0 else None
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"yn00 error: {e}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def parse_yn00_output(yn00_file: Path) -> List[Dict]:
|
||
|
|
"""解析yn00输出文件,提取Ka/Ks结果"""
|
||
|
|
results = []
|
||
|
|
current_pair = {}
|
||
|
|
|
||
|
|
with open(yn00_file, 'r') as f:
|
||
|
|
content = f.read()
|
||
|
|
|
||
|
|
lines = content.split('\n')
|
||
|
|
i = 0
|
||
|
|
while i < len(lines):
|
||
|
|
line = lines[i].strip()
|
||
|
|
if line.startswith('(') and 'vs.' in line:
|
||
|
|
match = re.search(r'\(([^)]+)\s+vs\.\s+([^)]+)\)', line)
|
||
|
|
if match:
|
||
|
|
gene1 = match.group(1).strip()
|
||
|
|
gene2 = match.group(2).strip()
|
||
|
|
current_pair = {'gene1': gene1, 'gene2': gene2}
|
||
|
|
|
||
|
|
j = i + 1
|
||
|
|
while j < len(lines) and j < i + 20:
|
||
|
|
next_line = lines[j].strip()
|
||
|
|
if 'Ka' in next_line and 'Ks' in next_line:
|
||
|
|
if j + 1 < len(lines):
|
||
|
|
values_line = lines[j + 1].strip()
|
||
|
|
parts = values_line.split()
|
||
|
|
if len(parts) >= 4:
|
||
|
|
try:
|
||
|
|
current_pair['Ka'] = float(parts[0])
|
||
|
|
current_pair['Ks'] = float(parts[1])
|
||
|
|
current_pair['Ka_Ks'] = float(parts[2]) if parts[2] != 'nan' else float('nan')
|
||
|
|
current_pair['method'] = parts[3] if len(parts) > 3 else 'YN00'
|
||
|
|
results.append(current_pair.copy())
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
break
|
||
|
|
j += 1
|
||
|
|
i += 1
|
||
|
|
|
||
|
|
return results
|
||
|
|
|
||
|
|
|
||
|
|
def calculate_divergence_time(ks_values: List[float], neutral_rate: float = 7.08e-9) -> Dict:
|
||
|
|
"""计算分歧时间"""
|
||
|
|
valid_ks = [ks for ks in ks_values if ks > 0 and not (ks != ks)]
|
||
|
|
|
||
|
|
if not valid_ks:
|
||
|
|
return {'mean_ks': None, 'median_ks': None, 'divergence_time_my': None}
|
||
|
|
|
||
|
|
mean_ks = sum(valid_ks) / len(valid_ks)
|
||
|
|
median_ks = sorted(valid_ks)[len(valid_ks) // 2]
|
||
|
|
|
||
|
|
# T = Ks / (2 * neutral_rate)
|
||
|
|
divergence_time = mean_ks / (2 * neutral_rate) / 1e6 # 转换为百万年
|
||
|
|
|
||
|
|
return {
|
||
|
|
'mean_ks': mean_ks,
|
||
|
|
'median_ks': median_ks,
|
||
|
|
'divergence_time_my': divergence_time,
|
||
|
|
'pair_count': len(valid_ks)
|
||
|
|
}
|