2804 lines
129 KiB
Python
Executable File
2804 lines
129 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Interspecies Synteny阶段:物种间共线性分析(完整版)
|
||
|
||
输入:
|
||
- 参考物种蛋白序列 (final/{prefix}.final_members.pep.fa)
|
||
- 参考物种GFF3
|
||
- 目标物种蛋白序列
|
||
- 目标物种GFF3
|
||
|
||
输出:
|
||
- final/{prefix}.interspecies_tree.nwk (物种间进化树)
|
||
- final/{prefix}.interspecies_tree.pdf/png (进化树图)
|
||
- final/{prefix}.interspecies_synteny.pdf/png (共线性图)
|
||
- final/{prefix}.interspecies_synteny_report.md (分析报告)
|
||
- final/{prefix}.interspecies_synteny.manifest.json (机器清单)
|
||
"""
|
||
|
||
import os
|
||
import argparse
|
||
import json
|
||
import hashlib
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import shutil
|
||
import re
|
||
import gzip
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List, Any, Optional, Tuple
|
||
|
||
try:
|
||
from gfa_core.gene_family_scope import read_scope, sanitize_token
|
||
from gfa_core.gene_family_stage_context import apply_stage_context, build_stage_context, infer_analysis_route, member_set_id_for
|
||
except Exception:
|
||
def read_scope(scope_path):
|
||
return json.loads(Path(scope_path).read_text(encoding="utf-8"))
|
||
|
||
def sanitize_token(value: str) -> str:
|
||
token = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip())
|
||
token = token.strip(".-_")
|
||
return token or "NA"
|
||
|
||
def infer_analysis_route(stage: str, scope_data: Dict[str, Any] | None = None) -> str:
|
||
scope_data = scope_data or {}
|
||
return "multi_species" if scope_data.get("reference_species") else "unscoped"
|
||
|
||
def member_set_id_for(prefix: str, scope_data: Dict[str, Any] | None, analysis_route: str) -> str:
|
||
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
|
||
return f"{sanitize_token(scope_id or prefix)}:{analysis_route}:all_members"
|
||
|
||
def build_stage_context(*args, **kwargs):
|
||
return None
|
||
|
||
def apply_stage_context(manifest: dict, context: Any) -> dict:
|
||
return manifest
|
||
|
||
|
||
# ??conda??bin???PATH
|
||
CONDA_BIN = "/home/xzq/miniconda3/envs/test4py311/bin"
|
||
if CONDA_BIN not in os.environ.get("PATH", ""):
|
||
os.environ["PATH"] = CONDA_BIN + ":" + os.environ.get("PATH", "")
|
||
|
||
|
||
def open_text(path: Path, mode: str = "rt"):
|
||
path = Path(path)
|
||
if path.suffix == ".gz":
|
||
return gzip.open(path, mode, encoding="utf-8", errors="ignore")
|
||
return open(path, mode.replace("t", ""), encoding="utf-8", errors="ignore")
|
||
|
||
|
||
def materialize_plain_fasta(source: Path, output_dir: Path, label: str) -> Path:
|
||
"""Return a plain, search-safe protein FASTA for DIAMOND/BLASTP."""
|
||
source = Path(source)
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
stem = source.name[:-3] if source.suffix.lower() == ".gz" else source.name
|
||
stem = stem or f"{sanitize_token(label)}.fa"
|
||
dest = output_dir / f"{sanitize_token(label)}.{stem}"
|
||
if dest.exists() and dest.stat().st_size > 0 and dest.stat().st_mtime >= source.stat().st_mtime:
|
||
return dest
|
||
print(f" Materializing search-safe protein FASTA: {source} -> {dest}")
|
||
replacements = 0
|
||
sequence_lines = 0
|
||
valid_letters = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||
with open_text(source, "rt") as src, dest.open("w", encoding="utf-8") as dst:
|
||
for line in src:
|
||
if line.startswith(">"):
|
||
dst.write(line.rstrip("\n") + "\n")
|
||
continue
|
||
cleaned = []
|
||
for char in line.strip().upper():
|
||
if not char:
|
||
continue
|
||
if char in valid_letters:
|
||
cleaned.append(char)
|
||
else:
|
||
cleaned.append("X")
|
||
replacements += 1
|
||
if cleaned:
|
||
dst.write("".join(cleaned) + "\n")
|
||
sequence_lines += 1
|
||
if not dest.exists() or dest.stat().st_size == 0:
|
||
raise RuntimeError(f"Failed to materialize protein FASTA: {source}")
|
||
if replacements:
|
||
print(f" Replaced {replacements} non-letter protein character(s) with X in {dest.name}")
|
||
if sequence_lines == 0:
|
||
raise RuntimeError(f"No sequence lines found in protein FASTA: {source}")
|
||
return dest
|
||
|
||
|
||
def pdf_to_png(pdf_path: Path, png_path: Path, dpi: int = 300) -> bool:
|
||
"""将PDF转换为PNG"""
|
||
try:
|
||
# 方法1: 使用pdftoppm (poppler-utils)
|
||
cmd = ["pdftoppm", "-png", "-r", str(dpi), "-singlefile",
|
||
str(pdf_path), str(png_path.with_suffix(''))]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||
if result.returncode == 0:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# 方法2: 使用convert (ImageMagick)
|
||
cmd = ["convert", "-density", str(dpi), str(pdf_path), "-quality", "100", str(png_path)]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||
if result.returncode == 0:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
|
||
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 current_input_dir(workspace: Path) -> Path:
|
||
return workspace.parent.parent / "input" / workspace.name
|
||
|
||
|
||
def looks_like_path_candidate(value: str) -> bool:
|
||
text = str(value or "").strip()
|
||
if not text or len(text) > 2048 or any(ch in text for ch in "\r\n\t"):
|
||
return False
|
||
if text.startswith(("/", "./", "../", "~")) or "/" in text or "\\" in text:
|
||
return True
|
||
if any(ch.isspace() for ch in text):
|
||
return False
|
||
return bool(Path(text).suffix)
|
||
|
||
|
||
def rebase_current_job_path(workspace: Path, raw_path: Any, input_dir: Optional[Path] = None) -> Optional[Path]:
|
||
path_text = str(raw_path or "").strip()
|
||
if not looks_like_path_candidate(path_text):
|
||
return None
|
||
path = Path(path_text)
|
||
input_dir = input_dir or current_input_dir(workspace)
|
||
parts = list(path.parts)
|
||
for marker, root in (("input", input_dir), ("workspace", workspace)):
|
||
if marker in parts:
|
||
idx = parts.index(marker)
|
||
if idx + 1 < len(parts) and parts[idx + 1] == workspace.name:
|
||
candidate = root.joinpath(*parts[idx + 2 :])
|
||
if candidate.exists():
|
||
return candidate
|
||
if not path.is_absolute():
|
||
for candidate in (Path.cwd() / path, input_dir / path, input_dir / path.name, workspace / path):
|
||
if candidate.exists():
|
||
return candidate
|
||
for candidate in (
|
||
input_dir / path.name,
|
||
workspace / "final" / path.name,
|
||
workspace / "interspecies_synteny" / "analysis" / path.name,
|
||
):
|
||
if candidate.exists():
|
||
return candidate
|
||
if path.exists():
|
||
return path
|
||
return None
|
||
|
||
|
||
def rebase_job_path_text(workspace: Path, value: str) -> str:
|
||
if not value:
|
||
return value
|
||
if looks_like_path_candidate(value):
|
||
direct = rebase_current_job_path(workspace, value)
|
||
if direct:
|
||
return str(direct.absolute())
|
||
job_id = re.escape(workspace.name)
|
||
pattern = re.compile(rf"/[^\s\"']*?(?:input|workspace)/{job_id}/[^\s\"']+")
|
||
|
||
def replace_match(match: re.Match[str]) -> str:
|
||
original = match.group(0).rstrip(",)]}")
|
||
suffix = match.group(0)[len(original):]
|
||
rebased = rebase_current_job_path(workspace, original)
|
||
if rebased:
|
||
return str(rebased.absolute()) + suffix
|
||
return match.group(0)
|
||
|
||
return pattern.sub(replace_match, value)
|
||
|
||
|
||
def rebase_manifest_payload_paths(workspace: Path, value: Any) -> Any:
|
||
if isinstance(value, dict):
|
||
return {key: rebase_manifest_payload_paths(workspace, item) for key, item in value.items()}
|
||
if isinstance(value, list):
|
||
return [rebase_manifest_payload_paths(workspace, item) for item in value]
|
||
if isinstance(value, str):
|
||
return rebase_job_path_text(workspace, value)
|
||
return value
|
||
|
||
|
||
def summarize_process_result(result: subprocess.CompletedProcess) -> Dict[str, Any]:
|
||
"""Keep process diagnostics compact and avoid persisting wrapped absolute paths."""
|
||
def clean_lines(text: str) -> List[str]:
|
||
cleaned: List[str] = []
|
||
for line in (text or "").splitlines():
|
||
if any(marker in line for marker in ("/data_old", ".bak", "bbav5_0502_Yang_Tihuan.7880")):
|
||
continue
|
||
stripped = line.strip()
|
||
if stripped:
|
||
cleaned.append(stripped)
|
||
return cleaned[-20:]
|
||
|
||
stdout = "\n".join(clean_lines(result.stdout))
|
||
stderr = "\n".join(clean_lines(result.stderr))
|
||
return {
|
||
"returncode": result.returncode,
|
||
"stdout_tail": stdout[:2000],
|
||
"stderr_tail": stderr[:2000],
|
||
}
|
||
|
||
|
||
def resolve_iqtree_executable() -> str:
|
||
"""Resolve IQ-TREE consistently with the phylogeny stage."""
|
||
candidates = [
|
||
Path(sys.executable).resolve().parent / "iqtree3",
|
||
Path(sys.executable).resolve().parent / "iqtree2",
|
||
shutil.which("iqtree3"),
|
||
shutil.which("iqtree2"),
|
||
shutil.which("iqtree"),
|
||
]
|
||
for candidate in candidates:
|
||
if not candidate:
|
||
continue
|
||
path = Path(str(candidate))
|
||
if path.exists() and os.access(path, os.X_OK):
|
||
return str(path)
|
||
if isinstance(candidate, str) and shutil.which(candidate):
|
||
return str(candidate)
|
||
return "iqtree3"
|
||
|
||
|
||
def get_tool_versions() -> Dict[str, str]:
|
||
"""获取所需工具版本信息"""
|
||
versions = {}
|
||
|
||
for tool, cmd in [
|
||
("mafft", ["mafft", "--version"]),
|
||
("iqtree", [resolve_iqtree_executable(), "--version"]),
|
||
("diamond", ["diamond", "--version"]),
|
||
("blastp", ["blastp", "-version"]),
|
||
("MCScanX", ["MCScanX", "-h"]),
|
||
]:
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=5)
|
||
versions[tool] = "available" if result.returncode in [0, 1] else "not_found"
|
||
except Exception:
|
||
versions[tool] = "not_found"
|
||
|
||
return versions
|
||
|
||
|
||
def prepare_directories(workspace: Path) -> Dict[str, Path]:
|
||
"""创建目录结构"""
|
||
paths = {
|
||
"analysis_dir": workspace / "interspecies_synteny" / "analysis",
|
||
"final_dir": workspace / "final",
|
||
"logs_dir": workspace / "logs",
|
||
}
|
||
for name, path in paths.items():
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return paths
|
||
|
||
|
||
def merge_protein_sequences(
|
||
target_protein: Path,
|
||
ref_protein: Path,
|
||
output_fasta: Path
|
||
) -> Dict[str, Any]:
|
||
"""Merge target and reference family proteins for the interspecies tree."""
|
||
print("Merging family protein sequences...")
|
||
|
||
try:
|
||
with open(output_fasta, 'w') as outfile:
|
||
with open_text(target_protein, 'rt') as f:
|
||
outfile.write(f.read().rstrip() + "\n")
|
||
with open_text(ref_protein, 'rt') as f:
|
||
outfile.write(f.read().rstrip() + "\n")
|
||
|
||
target_count = sum(1 for line in open_text(target_protein, 'rt') if line.startswith('>'))
|
||
ref_count = sum(1 for line in open_text(ref_protein, 'rt') if line.startswith('>'))
|
||
|
||
print(f" merged target/ref family proteins: {target_count} + {ref_count} = {target_count + ref_count}")
|
||
|
||
return {
|
||
"success": True,
|
||
"output_file": output_fasta,
|
||
"target_count": target_count,
|
||
"ref_count": ref_count
|
||
}
|
||
except Exception as e:
|
||
return {"success": False, "stderr": str(e)}
|
||
|
||
|
||
def build_phylogenetic_tree(
|
||
merged_fasta: Path,
|
||
output_dir: Path,
|
||
prefix: str,
|
||
cpu: int = 8
|
||
) -> Dict[str, Any]:
|
||
"""构建系统发育树 (MAFFT + IQ-TREE)"""
|
||
print(f"构建进化树...")
|
||
expected_tips = sum(1 for line in open_text(merged_fasta, "rt") if line.startswith(">"))
|
||
|
||
# MAFFT比对
|
||
msa_file = output_dir / f"{prefix}_interspecies.mafft.msa.fa"
|
||
mafft_cmd = ["mafft", "--auto", "--thread", str(cpu), str(merged_fasta)]
|
||
|
||
try:
|
||
with open(msa_file, "w") as f_out:
|
||
result = subprocess.run(mafft_cmd, stdout=f_out, stderr=subprocess.PIPE,
|
||
text=True, check=False)
|
||
|
||
if result.returncode != 0:
|
||
return {"success": False, "error": f"MAFFT失败: {result.stderr}"}
|
||
|
||
# IQ-TREE建树
|
||
base_name = output_dir / f"{prefix}_interspecies.iqtree"
|
||
tree_file = Path(f"{base_name}.treefile")
|
||
existing_tips = _read_tree_tip_labels(tree_file) if tree_file.exists() else set()
|
||
if existing_tips and len(existing_tips) != expected_tips:
|
||
print(
|
||
f" Removing stale IQ-TREE outputs ({len(existing_tips)} tips != {expected_tips} merged sequences)"
|
||
)
|
||
for stale in output_dir.glob(f"{prefix}_interspecies.iqtree*"):
|
||
try:
|
||
stale.unlink()
|
||
except OSError:
|
||
pass
|
||
iqtree_cmd = [
|
||
resolve_iqtree_executable(), "-s", str(msa_file), "-m", "MFP", "-bb", "1000",
|
||
"-nt", str(cpu), "-pre", str(base_name)
|
||
]
|
||
|
||
result = subprocess.run(iqtree_cmd, capture_output=True, text=True, check=False)
|
||
if result.returncode != 0:
|
||
return {"success": False, "error": f"IQ-TREE失败: {result.stderr or result.stdout}"}
|
||
|
||
if not tree_file.exists():
|
||
return {"success": False, "error": "IQ-TREE未生成 treefile"}
|
||
built_tips = _read_tree_tip_labels(tree_file)
|
||
if len(built_tips) != expected_tips:
|
||
return {
|
||
"success": False,
|
||
"error": f"IQ-TREE tree tip count ({len(built_tips)}) != merged sequence count ({expected_tips})",
|
||
}
|
||
|
||
return {
|
||
"success": True,
|
||
"tree_file": tree_file,
|
||
"msa_file": msa_file,
|
||
"tip_count": len(built_tips),
|
||
"iqtree_executable": iqtree_cmd[0],
|
||
"command": " ".join(iqtree_cmd),
|
||
}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
def filter_gff_files(
|
||
ref_gff3: Path,
|
||
target_gff3: Path,
|
||
output_dir: Path,
|
||
ref_species: str,
|
||
target_species: str
|
||
) -> Dict[str, Any]:
|
||
"""过滤GFF3文件 - 提取基因ID(去掉转录本版本后缀如 .1, .2)"""
|
||
print(f"过滤GFF3文件...")
|
||
|
||
import re
|
||
|
||
results = {}
|
||
for gff3_file, species, prefix in [
|
||
(ref_gff3, ref_species, "ref"),
|
||
(target_gff3, target_species, "target")
|
||
]:
|
||
output_gff = output_dir / f"{species}.gff"
|
||
|
||
try:
|
||
with open_text(gff3_file, 'rt') as f:
|
||
lines = f.readlines()
|
||
|
||
count = 0
|
||
seen_genes = set() # 去重
|
||
|
||
with open(output_gff, 'w') as f:
|
||
for line in lines:
|
||
parts = line.strip().split('\t')
|
||
if len(parts) >= 9 and parts[2] == "mRNA":
|
||
attr = parts[8]
|
||
# 提取ID(如 ID=AT2G47070.1),保留版本号与BLAST结果匹配
|
||
gene_id = attr.split(';')[0].replace('ID=', '')
|
||
|
||
# 处理染色体名称:去掉 "Chr" 前缀(Ath用1,2,3而非Chr1,Chr2)
|
||
chr_name = parts[0]
|
||
if chr_name.startswith('Chr'):
|
||
chr_name = chr_name[3:] # Chr1 -> 1
|
||
|
||
# 去重:一个基因可能有多个转录本
|
||
if gene_id not in seen_genes:
|
||
seen_genes.add(gene_id)
|
||
f.write(f"{chr_name}\t{gene_id}\t{parts[3]}\t{parts[4]}\n")
|
||
count += 1
|
||
|
||
print(f" {species}: {count} 个基因(去重后)")
|
||
results[prefix] = {"success": True, "output_file": output_gff, "gene_count": count}
|
||
except Exception as e:
|
||
results[prefix] = {"success": False, "stderr": str(e)}
|
||
|
||
if results.get("ref", {}).get("success") and results.get("target", {}).get("success"):
|
||
return {
|
||
"success": True,
|
||
"ref_gff": results["ref"]["output_file"],
|
||
"target_gff": results["target"]["output_file"]
|
||
}
|
||
return {"success": False}
|
||
|
||
|
||
def run_reciprocal_blast(
|
||
ref_protein: Path,
|
||
target_protein: Path,
|
||
output_dir: Path,
|
||
ref_species: str,
|
||
target_species: str,
|
||
cpu: int = 8
|
||
) -> Dict[str, Any]:
|
||
"""双向BLAST比对 - 结果中基因名去掉版本后缀以匹配GFF"""
|
||
import re
|
||
print(f"双向BLAST比对...")
|
||
|
||
# 构建数据库
|
||
for protein_file in [ref_protein, target_protein]:
|
||
subprocess.run(["makeblastdb", "-in", str(protein_file), "-dbtype", "prot"],
|
||
capture_output=True, check=False)
|
||
|
||
# 双向blastp
|
||
blast1_out = output_dir / f"tmp.{ref_species}_{target_species}.blast"
|
||
subprocess.run([
|
||
"blastp", "-query", str(ref_protein), "-db", str(target_protein),
|
||
"-outfmt", "6", "-out", str(blast1_out), "-evalue", "1e-10",
|
||
"-num_alignments", "5", "-num_threads", str(cpu)
|
||
], capture_output=True, check=False)
|
||
|
||
blast2_out = output_dir / f"tmp.{target_species}_{ref_species}.blast"
|
||
subprocess.run([
|
||
"blastp", "-query", str(target_protein), "-db", str(ref_protein),
|
||
"-outfmt", "6", "-out", str(blast2_out), "-evalue", "1e-10",
|
||
"-num_alignments", "5", "-num_threads", str(cpu)
|
||
], capture_output=True, check=False)
|
||
|
||
# 合并BLAST结果(保留版本号与GFF匹配)
|
||
merged_blast = output_dir / f"{ref_species}_{target_species}.blast"
|
||
with open(merged_blast, 'w') as outfile:
|
||
for blast_file in [blast1_out, blast2_out]:
|
||
if blast_file.exists():
|
||
with open(blast_file, 'r') as infile:
|
||
for line in infile:
|
||
outfile.write(line) # 直接写入,保留版本号
|
||
|
||
return {"success": True, "output_file": merged_blast}
|
||
|
||
|
||
def run_mcscanx_interspecies(
|
||
work_dir: Path,
|
||
ref_species: str,
|
||
target_species: str
|
||
) -> Dict[str, Any]:
|
||
"""MCScanX种间共线性 - 需要合并的GFF文件"""
|
||
print(f"MCScanX种间共线性...")
|
||
|
||
prefix = f"{ref_species}_{target_species}"
|
||
|
||
# MCScanX 需要合并的 GFF 文件 (prefix.gff)
|
||
merged_gff = work_dir / f"{prefix}.gff"
|
||
ref_gff = work_dir / f"{ref_species}.gff"
|
||
target_gff = work_dir / f"{target_species}.gff"
|
||
|
||
if not merged_gff.exists() and ref_gff.exists() and target_gff.exists():
|
||
print(f" 合并 GFF 文件: {ref_gff.name} + {target_gff.name} -> {merged_gff.name}")
|
||
with open(merged_gff, 'w') as outfile:
|
||
for gff_file in [target_gff, ref_gff]: # 目标物种在前,参考物种在后
|
||
with open(gff_file, 'r') as infile:
|
||
outfile.write(infile.read())
|
||
|
||
cmd = ["MCScanX", f"./{prefix}"]
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||
cwd=str(work_dir), check=False)
|
||
|
||
collinearity_file = work_dir / f"{prefix}.collinearity"
|
||
|
||
return {
|
||
"success": collinearity_file.exists(),
|
||
"collinearity_file": collinearity_file if collinearity_file.exists() else None
|
||
}
|
||
|
||
|
||
|
||
|
||
def fasta_ids(fasta_file: Path) -> List[str]:
|
||
ids = []
|
||
with open_text(fasta_file, "rt") as f:
|
||
for line in f:
|
||
if line.startswith(">"):
|
||
ids.append(line[1:].strip().split()[0])
|
||
return ids
|
||
|
||
|
||
def display_species_code(species: str, gene_id: str = "") -> str:
|
||
if re.match(r"^AT[1-5CM]G", gene_id, re.IGNORECASE):
|
||
return "AT"
|
||
return species
|
||
|
||
|
||
def species_for_gene(gene_id: str, species_list: List[str], default_species: str) -> str:
|
||
for species in sorted(species_list, key=len, reverse=True):
|
||
if gene_id.lower().startswith(species.lower()):
|
||
return display_species_code(species, gene_id)
|
||
if re.match(r"^AT[1-5CM]G", gene_id, re.IGNORECASE):
|
||
return "AT"
|
||
return display_species_code(default_species, gene_id)
|
||
|
||
|
||
def write_gene_species_map(target_fasta: Path, ref_fasta: Path, target_species: List[str], ref_species: List[str], output_file: Path) -> Path:
|
||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||
default_target = target_species[0] if target_species else "Target"
|
||
default_ref = ref_species[0] if ref_species else "Ref"
|
||
with open(output_file, "w") as out:
|
||
out.write("gene_id\tspecies\tgroup\n")
|
||
for gene_id in fasta_ids(target_fasta):
|
||
out.write(f"{gene_id}\t{species_for_gene(gene_id, target_species, default_target)}\ttarget\n")
|
||
for gene_id in fasta_ids(ref_fasta):
|
||
out.write(f"{gene_id}\t{species_for_gene(gene_id, ref_species, default_ref)}\tref\n")
|
||
return output_file
|
||
def normalize_seq_id(text: str) -> str:
|
||
"""Normalize FASTA/GFF IDs while preserving biologically meaningful versions."""
|
||
value = (text or "").strip()
|
||
if value.startswith(">"):
|
||
value = value[1:]
|
||
value = value.split()[0] if value.split() else value
|
||
if "|" in value:
|
||
value = value.split("|", 1)[0]
|
||
for prefix in ("gene:", "transcript:", "CDS:", "rna-", "gene-", "cds-"):
|
||
if value.startswith(prefix):
|
||
value = value[len(prefix):]
|
||
return value.strip().strip('"')
|
||
|
||
|
||
def id_variants(gene_id: str) -> set:
|
||
norm = normalize_seq_id(gene_id)
|
||
seeds = {norm}
|
||
if "|" in (gene_id or ""):
|
||
seeds.add(normalize_seq_id(gene_id.split("|", 1)[0]))
|
||
expanded = set()
|
||
for seed in seeds:
|
||
if not seed:
|
||
continue
|
||
expanded.update({seed, seed.upper(), seed.lower()})
|
||
if re.search(r"\.\d+$", seed) and not seed.lower().startswith(("evm.model.", "evm.tu.")):
|
||
stripped = re.sub(r"\.\d+$", "", seed)
|
||
expanded.update({stripped, stripped.upper(), stripped.lower()})
|
||
suffix_stripped = re.sub(r"_[PT]\d+$", "", seed)
|
||
expanded.update({suffix_stripped, suffix_stripped.upper(), suffix_stripped.lower()})
|
||
prefixed = set(expanded)
|
||
for value in expanded:
|
||
for prefix in ("gene:", "transcript:", "CDS:", "rna-", "gene-", "cds-"):
|
||
prefixed.add(normalize_seq_id(prefix + value))
|
||
return {v for v in prefixed if v}
|
||
|
||
|
||
def fasta_ids(fasta_file: Path) -> List[str]:
|
||
ids = []
|
||
with open_text(fasta_file, "rt") as f:
|
||
for line in f:
|
||
if line.startswith(">"):
|
||
seq_id = normalize_seq_id(line[1:])
|
||
if seq_id:
|
||
ids.append(seq_id)
|
||
return ids
|
||
|
||
|
||
def fasta_id_set(fasta_file: Path) -> set:
|
||
ids = set()
|
||
for seq_id in fasta_ids(fasta_file):
|
||
ids.update(id_variants(seq_id))
|
||
return ids
|
||
|
||
|
||
def display_species_code(species: str, gene_id: str = "") -> str:
|
||
if re.match(r"^AT[1-5CM]G", gene_id, re.IGNORECASE):
|
||
return "AT"
|
||
return species
|
||
|
||
|
||
def species_for_gene(gene_id: str, species_list: List[str], default_species: str, species_id_sets: Optional[Dict[str, set]] = None) -> str:
|
||
variants = id_variants(gene_id)
|
||
if species_id_sets:
|
||
for species, ids in species_id_sets.items():
|
||
if variants & ids:
|
||
return display_species_code(species, gene_id)
|
||
for species in sorted(species_list, key=len, reverse=True):
|
||
if normalize_seq_id(gene_id).lower().startswith(species.lower()):
|
||
return display_species_code(species, gene_id)
|
||
if re.match(r"^AT[1-5CM]G", gene_id, re.IGNORECASE):
|
||
return "AT"
|
||
return display_species_code(default_species, gene_id)
|
||
|
||
|
||
def collect_species_id_sets(groups: Dict[str, Any]) -> Dict[str, set]:
|
||
species_id_sets: Dict[str, set] = {}
|
||
for group_name in ("target", "ref"):
|
||
for species, item in groups.get(group_name, {}).items():
|
||
pep = item.get("resolved", {}).get("pep")
|
||
if pep and Path(pep).exists():
|
||
species_id_sets[species] = fasta_id_set(Path(pep))
|
||
return species_id_sets
|
||
|
||
|
||
def write_gene_species_map(
|
||
target_fasta: Path,
|
||
ref_fasta: Path,
|
||
target_species: List[str],
|
||
ref_species: List[str],
|
||
output_file: Path,
|
||
species_id_sets: Optional[Dict[str, set]] = None,
|
||
) -> Path:
|
||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||
default_target = target_species[0] if target_species else "Target"
|
||
default_ref = ref_species[0] if ref_species else "Ref"
|
||
with open(output_file, "w") as out:
|
||
out.write("gene_id\tspecies\tgroup\n")
|
||
for gene_id in fasta_ids(target_fasta):
|
||
species = species_for_gene(gene_id, target_species, default_target, species_id_sets)
|
||
out.write(f"{gene_id}\t{species}\ttarget\n")
|
||
for gene_id in fasta_ids(ref_fasta):
|
||
species = species_for_gene(gene_id, ref_species, default_ref, species_id_sets)
|
||
out.write(f"{gene_id}\t{species}\tref\n")
|
||
return output_file
|
||
|
||
|
||
def _merge_blast_outputs(blast_files: List[Path], merged_blast: Path) -> bool:
|
||
with open(merged_blast, "w") as outfile:
|
||
for blast_file in blast_files:
|
||
if blast_file.exists():
|
||
with open(blast_file, "r") as infile:
|
||
for line in infile:
|
||
if line.strip():
|
||
outfile.write(line)
|
||
return merged_blast.exists() and merged_blast.stat().st_size > 0
|
||
|
||
|
||
def _run_reciprocal_blastp_engine(
|
||
target_protein: Path,
|
||
ref_protein: Path,
|
||
output_dir: Path,
|
||
target_species: str,
|
||
ref_species: str,
|
||
cpu: int,
|
||
evalue: str,
|
||
max_target_seqs: int,
|
||
) -> Dict[str, Any]:
|
||
pair_prefix = f"{target_species}.{ref_species}"
|
||
target_db = output_dir / f"{target_species}.blastdb"
|
||
ref_db = output_dir / f"{ref_species}.blastdb"
|
||
commands = []
|
||
stderr_items = []
|
||
start_time = time.time()
|
||
|
||
for protein_file, db_prefix in [(target_protein, target_db), (ref_protein, ref_db)]:
|
||
cmd = ["makeblastdb", "-in", str(protein_file), "-dbtype", "prot", "-out", str(db_prefix)]
|
||
commands.append(" ".join(cmd))
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||
except FileNotFoundError:
|
||
return {"success": False, "stderr": "makeblastdb not found", "commands": commands, "engine_used": "blastp"}
|
||
if result.stderr:
|
||
stderr_items.append(result.stderr)
|
||
if result.returncode != 0:
|
||
return {"success": False, "stderr": "\n".join(stderr_items), "commands": commands, "engine_used": "blastp"}
|
||
|
||
target_to_ref = output_dir / f"tmp.{target_species}_{ref_species}.blast"
|
||
ref_to_target = output_dir / f"tmp.{ref_species}_{target_species}.blast"
|
||
for query, db_prefix, out_file in [(target_protein, ref_db, target_to_ref), (ref_protein, target_db, ref_to_target)]:
|
||
cmd = [
|
||
"blastp", "-query", str(query), "-db", str(db_prefix),
|
||
"-outfmt", "6", "-out", str(out_file), "-evalue", str(evalue),
|
||
"-num_alignments", str(max_target_seqs), "-num_threads", str(cpu),
|
||
]
|
||
commands.append(" ".join(cmd))
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||
except FileNotFoundError:
|
||
return {"success": False, "stderr": "blastp not found", "commands": commands, "engine_used": "blastp"}
|
||
if result.stderr:
|
||
stderr_items.append(result.stderr)
|
||
if result.returncode != 0:
|
||
return {"success": False, "stderr": "\n".join(stderr_items), "commands": commands, "engine_used": "blastp"}
|
||
|
||
merged_blast = output_dir / f"{pair_prefix}.blast"
|
||
success = _merge_blast_outputs([target_to_ref, ref_to_target], merged_blast)
|
||
return {
|
||
"success": success,
|
||
"output_file": merged_blast if success else None,
|
||
"commands": commands,
|
||
"stderr": "\n".join(stderr_items),
|
||
"output_stats": {
|
||
str(path.name): path.stat().st_size if path.exists() else -1
|
||
for path in [target_to_ref, ref_to_target, merged_blast]
|
||
},
|
||
"engine_used": "blastp",
|
||
"elapsed_sec": time.time() - start_time,
|
||
}
|
||
|
||
|
||
def _run_reciprocal_diamond_engine(
|
||
target_protein: Path,
|
||
ref_protein: Path,
|
||
output_dir: Path,
|
||
target_species: str,
|
||
ref_species: str,
|
||
cpu: int,
|
||
evalue: str,
|
||
max_target_seqs: int,
|
||
) -> Dict[str, Any]:
|
||
pair_prefix = f"{target_species}.{ref_species}"
|
||
target_db = output_dir / f"{target_species}.diamond_db"
|
||
ref_db = output_dir / f"{ref_species}.diamond_db"
|
||
commands = []
|
||
stderr_items = []
|
||
start_time = time.time()
|
||
|
||
for protein_file, db_prefix in [(target_protein, target_db), (ref_protein, ref_db)]:
|
||
cmd = ["diamond", "makedb", "--in", str(protein_file), "--db", str(db_prefix)]
|
||
commands.append(" ".join(cmd))
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||
except FileNotFoundError:
|
||
return {"success": False, "stderr": "diamond not found", "commands": commands, "engine_used": "diamond"}
|
||
if result.stderr:
|
||
stderr_items.append(result.stderr)
|
||
if result.returncode != 0:
|
||
return {"success": False, "stderr": "\n".join(stderr_items), "commands": commands, "engine_used": "diamond"}
|
||
|
||
target_to_ref = output_dir / f"tmp.{target_species}_{ref_species}.blast"
|
||
ref_to_target = output_dir / f"tmp.{ref_species}_{target_species}.blast"
|
||
for query, db_prefix, out_file in [(target_protein, ref_db, target_to_ref), (ref_protein, target_db, ref_to_target)]:
|
||
cmd = [
|
||
"diamond", "blastp", "--query", str(query), "--db", str(db_prefix),
|
||
"--out", str(out_file), "--outfmt", "6", "--max-target-seqs", str(max_target_seqs),
|
||
"--evalue", str(evalue), "--threads", str(cpu),
|
||
]
|
||
commands.append(" ".join(cmd))
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||
except FileNotFoundError:
|
||
return {"success": False, "stderr": "diamond not found", "commands": commands, "engine_used": "diamond"}
|
||
if result.stderr:
|
||
stderr_items.append(result.stderr)
|
||
if result.returncode != 0:
|
||
return {"success": False, "stderr": "\n".join(stderr_items), "commands": commands, "engine_used": "diamond"}
|
||
|
||
merged_blast = output_dir / f"{pair_prefix}.blast"
|
||
success = _merge_blast_outputs([target_to_ref, ref_to_target], merged_blast)
|
||
return {
|
||
"success": success,
|
||
"output_file": merged_blast if success else None,
|
||
"commands": commands,
|
||
"stderr": "\n".join(stderr_items),
|
||
"output_stats": {
|
||
str(path.name): path.stat().st_size if path.exists() else -1
|
||
for path in [target_to_ref, ref_to_target, merged_blast]
|
||
},
|
||
"engine_used": "diamond",
|
||
"elapsed_sec": time.time() - start_time,
|
||
}
|
||
|
||
|
||
def run_reciprocal_blast(
|
||
target_protein: Path,
|
||
ref_protein: Path,
|
||
output_dir: Path,
|
||
target_species: str,
|
||
ref_species: str,
|
||
cpu: int = 8,
|
||
engine: str = "diamond",
|
||
evalue: str = "1e-10",
|
||
max_target_seqs: int = 5,
|
||
) -> Dict[str, Any]:
|
||
"""Run pairwise reciprocal protein search for one target-ref species pair."""
|
||
target_protein = materialize_plain_fasta(target_protein, output_dir, f"{target_species}.search")
|
||
ref_protein = materialize_plain_fasta(ref_protein, output_dir, f"{ref_species}.search")
|
||
requested_engine = (engine or "diamond").strip().lower()
|
||
if requested_engine not in {"diamond", "blastp"}:
|
||
requested_engine = "diamond"
|
||
print(f"Reciprocal protein search ({requested_engine}): {target_species} vs {ref_species}")
|
||
|
||
if requested_engine == "blastp":
|
||
result = _run_reciprocal_blastp_engine(target_protein, ref_protein, output_dir, target_species, ref_species, cpu, evalue, max_target_seqs)
|
||
result["engine_requested"] = requested_engine
|
||
result["fallback_from"] = None
|
||
return result
|
||
|
||
result = _run_reciprocal_diamond_engine(target_protein, ref_protein, output_dir, target_species, ref_species, cpu, evalue, max_target_seqs)
|
||
if result.get("success"):
|
||
result["engine_requested"] = requested_engine
|
||
result["fallback_from"] = None
|
||
return result
|
||
|
||
diamond_stderr = result.get("stderr", "")
|
||
diamond_commands = list(result.get("commands", []))
|
||
print(f" Diamond failed for {target_species}.{ref_species}; falling back to NCBI blastp")
|
||
fallback = _run_reciprocal_blastp_engine(target_protein, ref_protein, output_dir, target_species, ref_species, cpu, evalue, max_target_seqs)
|
||
fallback["engine_requested"] = requested_engine
|
||
fallback["fallback_from"] = "diamond"
|
||
fallback["diamond_stderr"] = diamond_stderr
|
||
fallback["commands"] = diamond_commands + list(fallback.get("commands", []))
|
||
return fallback
|
||
|
||
|
||
def run_mcscanx_interspecies(work_dir: Path, target_species: str, ref_species: str) -> Dict[str, Any]:
|
||
"""Run MCScanX for one target-ref pair."""
|
||
pair_prefix = f"{target_species}.{ref_species}"
|
||
print(f"MCScanX pair: {pair_prefix}")
|
||
|
||
merged_gff = work_dir / f"{pair_prefix}.gff"
|
||
target_gff = work_dir / f"{target_species}.gff"
|
||
ref_gff = work_dir / f"{ref_species}.gff"
|
||
|
||
if target_gff.exists() and ref_gff.exists():
|
||
with open(merged_gff, 'w') as outfile:
|
||
for gff_file in [target_gff, ref_gff]:
|
||
with open(gff_file, 'r') as infile:
|
||
outfile.write(infile.read().rstrip() + "\n")
|
||
else:
|
||
return {"success": False, "error": f"Missing simplified GFF for {pair_prefix}"}
|
||
|
||
result = subprocess.run(["MCScanX", f"./{pair_prefix}"], capture_output=True, text=True, cwd=str(work_dir), check=False)
|
||
collinearity_file = work_dir / f"{pair_prefix}.collinearity"
|
||
return {
|
||
"success": collinearity_file.exists() and collinearity_file.stat().st_size > 0,
|
||
"collinearity_file": collinearity_file if collinearity_file.exists() else None,
|
||
"stdout": result.stdout,
|
||
"stderr": result.stderr,
|
||
"returncode": result.returncode,
|
||
}
|
||
|
||
|
||
def _read_tree_tip_labels(tree_path: Path) -> set[str]:
|
||
try:
|
||
content = tree_path.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
return set()
|
||
return {m.group(1).strip().strip("'").strip('"') for m in re.finditer(r"(?<=[(,])([^():,;]+):", content) if m.group(1).strip()}
|
||
|
||
|
||
def _merge_matching_subfamily_files(candidates: List[Path], tree_file: Path, output_dir: Path, prefix: str) -> Optional[Path]:
|
||
tips = _read_tree_tip_labels(tree_file)
|
||
if not tips:
|
||
return None
|
||
merged: Dict[str, str] = {}
|
||
used_files: List[str] = []
|
||
seen: set[Path] = set()
|
||
for candidate in candidates:
|
||
if not candidate or candidate in seen or not candidate.exists() or not candidate.is_file():
|
||
continue
|
||
seen.add(candidate)
|
||
matched_here = False
|
||
try:
|
||
with candidate.open("r", encoding="utf-8", errors="ignore") as handle:
|
||
for raw in handle:
|
||
parts = raw.strip().split()
|
||
if len(parts) < 2:
|
||
continue
|
||
gene_id, subfamily = parts[0], parts[1]
|
||
if gene_id in tips and gene_id not in merged:
|
||
merged[gene_id] = subfamily
|
||
matched_here = True
|
||
except Exception:
|
||
continue
|
||
if matched_here:
|
||
used_files.append(str(candidate))
|
||
if not merged:
|
||
return None
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
merged_path = output_dir / f"{prefix}.merged.geneID.subfamily"
|
||
with merged_path.open("w", encoding="utf-8") as handle:
|
||
for gene_id in sorted(merged):
|
||
handle.write(f"{gene_id}\t{merged[gene_id]}\n")
|
||
print(f" 合并亚家族文件: {merged_path} ({len(merged)}/{len(tips)} tips; sources={len(used_files)})")
|
||
return merged_path
|
||
|
||
|
||
def plot_interspecies_tree(
|
||
tree_file: Path,
|
||
output_dir: Path,
|
||
prefix: str,
|
||
target_species: str,
|
||
ref_species: str,
|
||
target_subfamily_file: Optional[Path] = None,
|
||
ref_subfamily_file: Optional[Path] = None,
|
||
gene_species_map: Optional[Path] = None,
|
||
subfamily_arc_enabled: bool = True,
|
||
subfamily_arc_gap: float = 1.2,
|
||
subfamily_arc_label_gap: float = 0.6,
|
||
subfamily_arc_label_size: float = 0.0,
|
||
) -> Dict[str, Any]:
|
||
"""绘制物种间进化树图 - 使用专用的 interspecies_tree.R 脚本"""
|
||
print(f"绘制进化树图...")
|
||
|
||
# 查找专用的种间进化树R脚本
|
||
r_script = Path("scripts/interspecies_tree.R")
|
||
if not r_script.exists():
|
||
return {"success": False, "error": f"R脚本不存在: {r_script}"}
|
||
|
||
try:
|
||
# 使用专用的 interspecies_tree.R 脚本,传递物种参数和亚家族文件
|
||
# 使用conda环境的Rscript
|
||
conda_rscript = "/home/xzq/miniconda3/envs/test4py311/bin/Rscript"
|
||
cmd = [
|
||
conda_rscript, str(r_script),
|
||
str(tree_file),
|
||
str(output_dir),
|
||
target_species,
|
||
ref_species
|
||
]
|
||
|
||
# 添加亚家族文件参数(如果存在)
|
||
if target_subfamily_file and target_subfamily_file.exists():
|
||
cmd.append(str(target_subfamily_file))
|
||
print(f" 目标物种亚家族文件: {target_subfamily_file}")
|
||
else:
|
||
cmd.append("") # 空字符串表示无文件
|
||
|
||
if ref_subfamily_file and ref_subfamily_file.exists():
|
||
cmd.append(str(ref_subfamily_file))
|
||
print(f" 参考物种亚家族文件: {ref_subfamily_file}")
|
||
else:
|
||
cmd.append("") # 空字符串表示无文件
|
||
if gene_species_map and gene_species_map.exists():
|
||
cmd.append(str(gene_species_map))
|
||
print(f" Gene-to-species map: {gene_species_map}")
|
||
else:
|
||
cmd.append("")
|
||
cmd.extend([
|
||
str(0.4),
|
||
str(2.8),
|
||
"black",
|
||
str(0.5),
|
||
"triangle",
|
||
"circle",
|
||
str(3.0),
|
||
str(2.0),
|
||
"true" if subfamily_arc_enabled else "false",
|
||
str(subfamily_arc_gap),
|
||
str(subfamily_arc_label_gap),
|
||
str(subfamily_arc_label_size if subfamily_arc_label_size and subfamily_arc_label_size > 0 else ""),
|
||
])
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=120)
|
||
|
||
# 调试输出
|
||
print(f" R脚本返回码: {result.returncode}")
|
||
if result.stdout:
|
||
print(f" R脚本输出: {result.stdout[:500]}")
|
||
if result.stderr:
|
||
print(f" R脚本错误: {result.stderr[:500]}")
|
||
|
||
# 专用脚本输出固定为 interspecies_tree.pdf/png
|
||
output_pdf = output_dir / "interspecies_tree.pdf"
|
||
output_png = output_dir / "interspecies_tree.png"
|
||
output_circular_pdf = output_dir / "interspecies_tree.circular_tree.pdf"
|
||
output_circular_png = output_dir / "interspecies_tree.circular_tree.png"
|
||
|
||
# 重命名为规范名称
|
||
final_pdf = output_dir / f"{prefix}.interspecies_tree.pdf"
|
||
final_png = output_dir / f"{prefix}.interspecies_tree.png"
|
||
final_circular_pdf = output_dir / f"{prefix}.interspecies_tree.circular_tree.pdf"
|
||
final_circular_png = output_dir / f"{prefix}.interspecies_tree.circular_tree.png"
|
||
|
||
if output_pdf.exists():
|
||
shutil.move(output_pdf, final_pdf)
|
||
print(f" 进化树PDF: {final_pdf}")
|
||
if output_png.exists():
|
||
shutil.move(output_png, final_png)
|
||
print(f" 进化树PNG: {final_png}")
|
||
if output_circular_pdf.exists():
|
||
shutil.move(output_circular_pdf, final_circular_pdf)
|
||
print(f" 进化树圈图PDF: {final_circular_pdf}")
|
||
if output_circular_png.exists():
|
||
shutil.move(output_circular_png, final_circular_png)
|
||
print(f" 进化树圈图PNG: {final_circular_png}")
|
||
|
||
# PDF转PNG(如果没有生成PNG)
|
||
if final_pdf.exists() and not final_png.exists():
|
||
png_from_pdf = output_dir / "interspecies_tree.png"
|
||
if pdf_to_png(final_pdf, png_from_pdf):
|
||
final_png = output_dir / f"{prefix}.interspecies_tree.png"
|
||
shutil.move(png_from_pdf, final_png)
|
||
print(f" PDF转PNG: {final_png}")
|
||
|
||
return {
|
||
"success": final_pdf.exists() or final_png.exists(),
|
||
"pdf_file": final_pdf if final_pdf.exists() else None,
|
||
"png_file": final_png if final_png.exists() else None,
|
||
"circular_pdf_file": final_circular_pdf if final_circular_pdf.exists() else None,
|
||
"circular_png_file": final_circular_png if final_circular_png.exists() else None,
|
||
"stdout": result.stdout,
|
||
"stderr": result.stderr,
|
||
"subfamily_arc_enabled": subfamily_arc_enabled,
|
||
"subfamily_arc_gap": subfamily_arc_gap,
|
||
"subfamily_arc_label_gap": subfamily_arc_label_gap,
|
||
"subfamily_arc_label_size": subfamily_arc_label_size,
|
||
}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
def convert_blast_to_family_simple(
|
||
work_dir: Path,
|
||
blast_file: Path,
|
||
target_species: str,
|
||
ref_species: str,
|
||
) -> Dict[str, Any]:
|
||
"""Fallback: convert pairwise BLAST hits directly to JCVI anchors/simple.
|
||
|
||
This is not a strict MCScanX collinearity block. It is used when the ref side
|
||
only has family proteins, so MCScanX cannot satisfy whole-genome block rules.
|
||
"""
|
||
pair_prefix = f"{target_species}.{ref_species}"
|
||
target_bed = work_dir / f"{target_species}.bed"
|
||
ref_bed = work_dir / f"{ref_species}.bed"
|
||
anchors_file = work_dir / f"{pair_prefix}.anchors"
|
||
simple_file = work_dir / f"{pair_prefix}.anchors.simple"
|
||
|
||
valid_target = {line.rstrip("\n").split("\t")[3] for line in open(target_bed) if len(line.rstrip("\n").split("\t")) >= 4}
|
||
valid_ref = {line.rstrip("\n").split("\t")[3] for line in open(ref_bed) if len(line.rstrip("\n").split("\t")) >= 4}
|
||
seen = set()
|
||
links = []
|
||
if not blast_file.exists():
|
||
return {"success": False, "error": "BLAST file missing for family-only fallback"}
|
||
with open(blast_file, "r") as f:
|
||
for line in f:
|
||
if not line.strip() or line.startswith("#"):
|
||
continue
|
||
parts = line.rstrip("\n").split("\t")
|
||
if len(parts) < 2:
|
||
continue
|
||
a = normalize_seq_id(parts[0])
|
||
b = normalize_seq_id(parts[1])
|
||
target_gene = ref_gene = None
|
||
if a in valid_target and b in valid_ref:
|
||
target_gene, ref_gene = a, b
|
||
elif b in valid_target and a in valid_ref:
|
||
target_gene, ref_gene = b, a
|
||
if not target_gene or not ref_gene:
|
||
continue
|
||
key = (target_gene, ref_gene)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
score_text = parts[11] if len(parts) > 11 else "1000"
|
||
try:
|
||
score = str(int(float(score_text)))
|
||
except Exception:
|
||
score = "1000"
|
||
links.append((target_gene, ref_gene, score))
|
||
if not links:
|
||
return {"success": False, "error": "No BLAST hits matched pair BED IDs for family-only fallback"}
|
||
with open(anchors_file, "w") as out:
|
||
out.write("##\n")
|
||
for target_gene, ref_gene, score in links:
|
||
out.write(f"{target_gene}\t{ref_gene}\t{score}\n")
|
||
with open(simple_file, "w") as out:
|
||
for target_gene, ref_gene, score in links:
|
||
out.write(f"{target_gene}\t{target_gene}\t{ref_gene}\t{ref_gene}\t{score}\t+\n")
|
||
return {
|
||
"success": True,
|
||
"anchors_file": anchors_file,
|
||
"simple_file": simple_file,
|
||
"anchor_count": len(links),
|
||
"fallback": "family_only_blast_links",
|
||
}
|
||
|
||
|
||
def convert_collinearity_to_simple(
|
||
work_dir: Path,
|
||
collinearity_file: Path,
|
||
target_species: str,
|
||
ref_species: str,
|
||
) -> Dict[str, Any]:
|
||
"""Convert one MCScanX .collinearity file into JCVI anchors/simple files."""
|
||
conda_python = "/home/xzq/miniconda3/envs/test4py311/bin/python"
|
||
pair_prefix = f"{target_species}.{ref_species}"
|
||
target_bed = work_dir / f"{target_species}.bed"
|
||
ref_bed = work_dir / f"{ref_species}.bed"
|
||
anchors_file = work_dir / f"{pair_prefix}.anchors"
|
||
anchors_new_file = work_dir / f"{pair_prefix}.anchors.new"
|
||
simple_file = work_dir / f"{pair_prefix}.anchors.simple"
|
||
|
||
valid_target = {line.rstrip("\n").split("\t")[3] for line in open(target_bed) if len(line.rstrip("\n").split("\t")) >= 4}
|
||
valid_ref = {line.rstrip("\n").split("\t")[3] for line in open(ref_bed) if len(line.rstrip("\n").split("\t")) >= 4}
|
||
|
||
current_block = []
|
||
anchors = 0
|
||
filtered = 0
|
||
with open(collinearity_file, "r") as f_in, open(anchors_file, "w") as f_out:
|
||
for line in f_in:
|
||
if not line.strip():
|
||
continue
|
||
if line.startswith("## Alignment"):
|
||
if current_block:
|
||
f_out.write("##\n")
|
||
f_out.writelines(current_block)
|
||
current_block = []
|
||
continue
|
||
if line.startswith("#"):
|
||
continue
|
||
parts = line.strip().split("\t")
|
||
if len(parts) < 3:
|
||
continue
|
||
gene_a = normalize_seq_id(parts[1])
|
||
gene_b = normalize_seq_id(parts[2])
|
||
target_gene = ref_gene = None
|
||
if gene_a in valid_target and gene_b in valid_ref:
|
||
target_gene, ref_gene = gene_a, gene_b
|
||
elif gene_b in valid_target and gene_a in valid_ref:
|
||
target_gene, ref_gene = gene_b, gene_a
|
||
if target_gene and ref_gene:
|
||
current_block.append(f"{target_gene} {ref_gene} 1000\n")
|
||
anchors += 1
|
||
else:
|
||
filtered += 1
|
||
if current_block:
|
||
f_out.write("##\n")
|
||
f_out.writelines(current_block)
|
||
|
||
if anchors == 0:
|
||
return {"success": False, "error": "No anchors matched pair BED IDs", "anchors_file": anchors_file, "filtered": filtered}
|
||
|
||
cmd_screen = [
|
||
conda_python, "-m", "jcvi.compara.synteny", "screen",
|
||
"--minspan=10", "--simple", str(anchors_file.absolute()), str(anchors_new_file.absolute()),
|
||
f"--qbed={target_bed.name}", f"--sbed={ref_bed.name}",
|
||
]
|
||
result = subprocess.run(cmd_screen, capture_output=True, text=True, cwd=str(work_dir), check=False, timeout=180)
|
||
screen_summary = summarize_process_result(result)
|
||
for candidate in (work_dir / f"{pair_prefix}.anchors.new.simple", work_dir / f"{pair_prefix}.new.simple"):
|
||
if candidate.exists():
|
||
shutil.copy(candidate, simple_file)
|
||
break
|
||
if not simple_file.exists():
|
||
shutil.copy(anchors_file, simple_file)
|
||
|
||
return {
|
||
"success": simple_file.exists() and simple_file.stat().st_size > 0,
|
||
"anchors_file": anchors_file,
|
||
"anchors_new_file": anchors_new_file,
|
||
"simple_file": simple_file,
|
||
"anchor_count": anchors,
|
||
"filtered_count": filtered,
|
||
"screen_returncode": result.returncode,
|
||
"screen_stderr": screen_summary.get("stderr_tail", ""),
|
||
}
|
||
|
||
|
||
def _chr_sort_key(chrom: str):
|
||
cleaned = re.sub(r"^(chr|Chr|CHR)", "", chrom)
|
||
if cleaned.isdigit():
|
||
return (0, int(cleaned))
|
||
return (1, cleaned)
|
||
|
||
|
||
def _read_bed_chroms(bed_file: Path) -> List[str]:
|
||
skip = {"C", "M", "ChrC", "ChrM", "chrc", "chrm"}
|
||
chroms = set()
|
||
with open(bed_file, "r") as f:
|
||
for line in f:
|
||
parts = line.strip().split("\t")
|
||
if len(parts) >= 4 and parts[0] not in skip:
|
||
chroms.add(parts[0])
|
||
return sorted(chroms, key=_chr_sort_key)
|
||
|
||
|
||
def _bed_gene_ids(bed_file: Path) -> set:
|
||
ids = set()
|
||
if not bed_file.exists():
|
||
return ids
|
||
with open(bed_file, "r") as f:
|
||
for line in f:
|
||
parts = line.rstrip("\n").split("\t")
|
||
if len(parts) >= 4:
|
||
ids.add(normalize_seq_id(parts[3]))
|
||
return ids
|
||
|
||
|
||
def _is_family_gene(gene_id: str, family_ids: set) -> bool:
|
||
return bool(set(id_variants(gene_id)) & family_ids)
|
||
|
||
|
||
def _family_links_from_blast(
|
||
blast_file: Path,
|
||
family_ids: set,
|
||
target_bed_ids: set,
|
||
ref_bed_ids: set,
|
||
max_links: int = 300,
|
||
) -> List[Tuple[str, str, int]]:
|
||
if not blast_file or not blast_file.exists() or not family_ids:
|
||
return []
|
||
best: Dict[Tuple[str, str], float] = {}
|
||
with open(blast_file, "r") as f:
|
||
for line in f:
|
||
parts = line.rstrip("\n").split("\t")
|
||
if len(parts) < 12:
|
||
continue
|
||
q, s = normalize_seq_id(parts[0]), normalize_seq_id(parts[1])
|
||
if q not in target_bed_ids or s not in ref_bed_ids:
|
||
continue
|
||
if not (_is_family_gene(q, family_ids) and _is_family_gene(s, family_ids)):
|
||
continue
|
||
try:
|
||
score = float(parts[11])
|
||
except Exception:
|
||
score = 1.0
|
||
key = (q, s)
|
||
if score > best.get(key, -1):
|
||
best[key] = score
|
||
ranked = sorted(best.items(), key=lambda item: item[1], reverse=True)[:max_links]
|
||
return [(q, s, max(1, int(round(score)))) for (q, s), score in ranked]
|
||
|
||
|
||
def _family_block_signatures_from_anchors(anchors_file: Path, family_ids: set) -> set:
|
||
"""Return JCVI simple-row signatures for blocks containing family genes.
|
||
|
||
JCVI's anchors.new keeps full block membership, while anchors.simple stores
|
||
only block endpoints. Published-style family highlighting should mark the
|
||
whole block when any family member occurs inside the block, not only when the
|
||
member happens to be one of the simple endpoints.
|
||
"""
|
||
signatures = set()
|
||
if not anchors_file or not anchors_file.exists() or not family_ids:
|
||
return signatures
|
||
|
||
def flush(block: List[Tuple[str, str]]) -> None:
|
||
if not block:
|
||
return
|
||
contains_family = any(_is_family_gene(q, family_ids) or _is_family_gene(s, family_ids) for q, s in block)
|
||
if not contains_family:
|
||
return
|
||
q1, s1 = block[0]
|
||
q2, s2 = block[-1]
|
||
signatures.add((frozenset((q1, q2)), frozenset((s1, s2))))
|
||
|
||
block: List[Tuple[str, str]] = []
|
||
with open(anchors_file, "r") as f:
|
||
for raw in f:
|
||
line = raw.strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith("###") or line.startswith("#"):
|
||
flush(block)
|
||
block = []
|
||
continue
|
||
parts = line.split()
|
||
if len(parts) < 2:
|
||
continue
|
||
block.append((normalize_seq_id(parts[0]), normalize_seq_id(parts[1])))
|
||
flush(block)
|
||
return signatures
|
||
|
||
|
||
def _simple_block_signature(parts: List[str]) -> Optional[Tuple[frozenset, frozenset]]:
|
||
if len(parts) < 4:
|
||
return None
|
||
q1, q2, s1, s2 = [normalize_seq_id(p.split("*", 1)[-1]) for p in parts[:4]]
|
||
return (frozenset((q1, q2)), frozenset((s1, s2)))
|
||
|
||
|
||
def _write_marked_simple_file(
|
||
simple_file: Path,
|
||
marked_file: Path,
|
||
family_ids: set,
|
||
family_links: List[Tuple[str, str, int]],
|
||
anchors_new_file: Optional[Path] = None,
|
||
) -> int:
|
||
if not simple_file.exists() or not family_ids:
|
||
return 0
|
||
family_block_signatures = _family_block_signatures_from_anchors(anchors_new_file, family_ids) if anchors_new_file else set()
|
||
marked_count = 0
|
||
seen_rows = set()
|
||
with open(simple_file, "r") as f_in, open(marked_file, "w") as f_out:
|
||
for line in f_in:
|
||
stripped = line.strip()
|
||
if not stripped or stripped.startswith("#"):
|
||
f_out.write(line)
|
||
continue
|
||
parts = stripped.split()
|
||
genes = [normalize_seq_id(p.split("*", 1)[-1]) for p in parts[:4]]
|
||
row_key = tuple(parts[:6]) if len(parts) >= 6 else tuple(parts)
|
||
row_sig = _simple_block_signature(parts)
|
||
seen_rows.add(row_key)
|
||
direct_endpoint_hit = any(_is_family_gene(g, family_ids) for g in genes)
|
||
block_hit = row_sig in family_block_signatures if row_sig is not None else False
|
||
if (direct_endpoint_hit or block_hit) and "*" not in parts[0]:
|
||
f_out.write("#e31a1c*" + "\t".join(parts) + "\n")
|
||
marked_count += 1
|
||
else:
|
||
f_out.write(line)
|
||
for q, s, score in family_links:
|
||
row = (q, q, s, s, str(score), "+")
|
||
if row in seen_rows:
|
||
continue
|
||
f_out.write("#e31a1c*" + "\t".join(row) + "\n")
|
||
seen_rows.add(row)
|
||
marked_count += 1
|
||
return marked_count
|
||
|
||
def plot_multi_species_synteny(
|
||
work_dir: Path,
|
||
output_dir: Path,
|
||
prefix: str,
|
||
species_order: List[str],
|
||
pair_results: List[Dict[str, Any]],
|
||
family_ids: set,
|
||
) -> Dict[str, Any]:
|
||
"""Draw one multi-track JCVI karyotype figure from all successful target-ref pairs."""
|
||
print("Plotting multi-species JCVI synteny...")
|
||
conda_python = "/home/xzq/miniconda3/envs/test4py311/bin/python"
|
||
usable_pairs = [p for p in pair_results if p.get("simple_file") and Path(p["simple_file"]).exists()]
|
||
if not usable_pairs:
|
||
return {"success": False, "error": "No pairwise anchors.simple files available for JCVI"}
|
||
|
||
seqids_file = work_dir / f"{prefix}.seqids"
|
||
layout_file = work_dir / f"{prefix}.layout"
|
||
layout_mark_file = work_dir / f"{prefix}.layout_mark"
|
||
|
||
species_with_bed = []
|
||
with open(seqids_file, "w") as f:
|
||
for species in species_order:
|
||
bed = work_dir / f"{species}.bed"
|
||
if not bed.exists():
|
||
continue
|
||
chroms = _read_bed_chroms(bed)
|
||
if not chroms:
|
||
continue
|
||
species_with_bed.append(species)
|
||
f.write(",".join(chroms) + "\n")
|
||
|
||
if len(species_with_bed) < 2:
|
||
return {"success": False, "error": "Fewer than two species BED tracks are available"}
|
||
|
||
index = {species: i for i, species in enumerate(species_with_bed)}
|
||
n = len(species_with_bed)
|
||
y_values = [round(0.85 - (0.7 * i / max(n - 1, 1)), 3) for i in range(n)]
|
||
palette = ["#d73027", "#1a9850", "#4575b4", "#984ea3", "#ff7f00", "#4daf4a", "#377eb8", "#a65628"]
|
||
|
||
def write_layout(layout: Path, use_marked: bool) -> None:
|
||
with open(layout, "w") as f:
|
||
f.write("# y, xstart, xend, rotation, color, label, va, bed\n")
|
||
for i, species in enumerate(species_with_bed):
|
||
va = "top" if i == 0 else "bottom"
|
||
f.write(f"{y_values[i]}, .08, .92, 0, {palette[i % len(palette)]}, {species}, {va}, {species}.bed\n")
|
||
f.write("# edges\n")
|
||
for pair in usable_pairs:
|
||
target = pair["target_species"]
|
||
ref = pair["ref_species"]
|
||
if target not in index or ref not in index:
|
||
continue
|
||
edge = Path(pair.get("marked_simple_file") if use_marked and pair.get("marked_simple_file") else pair["simple_file"]).name
|
||
f.write(f"e, {index[target]}, {index[ref]}, {edge}\n")
|
||
|
||
for pair in usable_pairs:
|
||
simple = Path(pair["simple_file"])
|
||
marked = work_dir / f"{pair['target_species']}.{pair['ref_species']}.anchors.simple.mark"
|
||
target_bed_ids = _bed_gene_ids(work_dir / f"{pair['target_species']}.bed")
|
||
ref_bed_ids = _bed_gene_ids(work_dir / f"{pair['ref_species']}.bed")
|
||
family_links = _family_links_from_blast(Path(pair.get("blast_file", "")), family_ids, target_bed_ids, ref_bed_ids)
|
||
marked_count = _write_marked_simple_file(simple, marked, family_ids, family_links, Path(pair.get("anchors_new_file", "")))
|
||
pair["family_highlight_links"] = len(family_links)
|
||
pair["marked_block_count"] = marked_count
|
||
if marked_count > 0:
|
||
pair["marked_simple_file"] = str(marked)
|
||
|
||
write_layout(layout_file, use_marked=False)
|
||
write_layout(layout_mark_file, use_marked=True)
|
||
|
||
def run_plot(layout: Path, final_pdf: Path, final_png: Path) -> Dict[str, Any]:
|
||
for stale in [work_dir / "karyotype.pdf", work_dir / "karyotype.png", final_pdf, final_png]:
|
||
if stale.exists():
|
||
stale.unlink()
|
||
cmd = [conda_python, "-m", "jcvi.graphics.karyotype", str(seqids_file.absolute()), str(layout.absolute()), "--notex"]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(work_dir), check=False, timeout=180)
|
||
if (work_dir / "karyotype.pdf").exists():
|
||
shutil.copy(work_dir / "karyotype.pdf", final_pdf.absolute())
|
||
if (work_dir / "karyotype.png").exists():
|
||
shutil.copy(work_dir / "karyotype.png", final_png.absolute())
|
||
if final_pdf.exists() and (not final_png.exists() or final_png.stat().st_mtime < final_pdf.stat().st_mtime):
|
||
pdf_to_png(final_pdf, final_png)
|
||
return summarize_process_result(result)
|
||
|
||
final_pdf = output_dir / f"{prefix}.interspecies_synteny.pdf"
|
||
final_png = output_dir / f"{prefix}.interspecies_synteny.png"
|
||
final_mark_pdf = output_dir / f"{prefix}.interspecies_synteny.marked.pdf"
|
||
final_mark_png = output_dir / f"{prefix}.interspecies_synteny.marked.png"
|
||
|
||
base_result = run_plot(layout_file, final_pdf, final_png)
|
||
mark_result = run_plot(layout_mark_file, final_mark_pdf, final_mark_png)
|
||
|
||
return {
|
||
"success": final_pdf.exists() or final_png.exists(),
|
||
"pdf_file": final_pdf if final_pdf.exists() else None,
|
||
"png_file": final_png if final_png.exists() else None,
|
||
"marked_pdf_file": final_mark_pdf if final_mark_pdf.exists() else None,
|
||
"marked_png_file": final_mark_png if final_mark_png.exists() else None,
|
||
"seqids_file": seqids_file,
|
||
"layout_file": layout_file,
|
||
"layout_mark_file": layout_mark_file,
|
||
"base_plot": base_result,
|
||
"marked_plot": mark_result,
|
||
}
|
||
|
||
|
||
def plot_pairwise_synteny(
|
||
work_dir: Path,
|
||
output_dir: Path,
|
||
prefix: str,
|
||
pair_results: List[Dict[str, Any]],
|
||
family_ids: set,
|
||
) -> Dict[str, Any]:
|
||
"""Draw one JCVI karyotype figure for each successful target-ref pair."""
|
||
print("Plotting pairwise JCVI synteny...")
|
||
conda_python = "/home/xzq/miniconda3/envs/test4py311/bin/python"
|
||
usable_pairs = [p for p in pair_results if p.get("success") and p.get("simple_file") and Path(p["simple_file"]).exists()]
|
||
if not usable_pairs:
|
||
return {"success": False, "error": "No successful pairwise anchors.simple files available for JCVI", "pairs": []}
|
||
|
||
pair_outputs: List[Dict[str, Any]] = []
|
||
palette = ["#d73027", "#4575b4"]
|
||
|
||
def run_plot(seqids: Path, layout: Path, final_pdf: Path, final_png: Path) -> Dict[str, Any]:
|
||
for stale in [work_dir / "karyotype.pdf", work_dir / "karyotype.png", final_pdf, final_png]:
|
||
if stale.exists():
|
||
stale.unlink()
|
||
cmd = [conda_python, "-m", "jcvi.graphics.karyotype", str(seqids.absolute()), str(layout.absolute()), "--notex"]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(work_dir), check=False, timeout=180)
|
||
if (work_dir / "karyotype.pdf").exists():
|
||
shutil.copy(work_dir / "karyotype.pdf", final_pdf.absolute())
|
||
if (work_dir / "karyotype.png").exists():
|
||
shutil.copy(work_dir / "karyotype.png", final_png.absolute())
|
||
if final_pdf.exists() and (not final_png.exists() or final_png.stat().st_mtime < final_pdf.stat().st_mtime):
|
||
pdf_to_png(final_pdf, final_png)
|
||
return summarize_process_result(result)
|
||
|
||
for pair in usable_pairs:
|
||
target = pair["target_species"]
|
||
ref = pair["ref_species"]
|
||
pair_key = f"{target}.{ref}"
|
||
target_bed = work_dir / f"{target}.bed"
|
||
ref_bed = work_dir / f"{ref}.bed"
|
||
simple = Path(pair["simple_file"])
|
||
if not target_bed.exists() or not ref_bed.exists():
|
||
pair_outputs.append({"pair": pair_key, "success": False, "error": "missing pair BED file"})
|
||
continue
|
||
|
||
marked = work_dir / f"{pair_key}.anchors.simple.mark"
|
||
target_bed_ids = _bed_gene_ids(target_bed)
|
||
ref_bed_ids = _bed_gene_ids(ref_bed)
|
||
family_links = _family_links_from_blast(Path(pair.get("blast_file", "")), family_ids, target_bed_ids, ref_bed_ids)
|
||
marked_count = _write_marked_simple_file(simple, marked, family_ids, family_links, Path(pair.get("anchors_new_file", "")))
|
||
pair["pairwise_family_highlight_links"] = len(family_links)
|
||
pair["pairwise_marked_block_count"] = marked_count
|
||
if marked_count > 0:
|
||
pair["marked_simple_file"] = str(marked)
|
||
|
||
seqids_file = work_dir / f"{pair_key}.seqids"
|
||
layout_file = work_dir / f"{pair_key}.layout"
|
||
layout_mark_file = work_dir / f"{pair_key}.layout_mark"
|
||
species_rows = [(target, target_bed), (ref, ref_bed)]
|
||
with open(seqids_file, "w") as f:
|
||
for _, bed in species_rows:
|
||
chroms = _read_bed_chroms(bed)
|
||
if not chroms:
|
||
pair_outputs.append({"pair": pair_key, "success": False, "error": f"no chromosomes found in {bed.name}"})
|
||
break
|
||
f.write(",".join(chroms) + "\n")
|
||
if pair_outputs and pair_outputs[-1].get("pair") == pair_key and not pair_outputs[-1].get("success") and pair_outputs[-1].get("error", "").startswith("no chromosomes"):
|
||
continue
|
||
|
||
def write_layout(layout: Path, use_marked: bool) -> None:
|
||
edge_file = Path(pair.get("marked_simple_file") if use_marked and pair.get("marked_simple_file") else simple).name
|
||
with open(layout, "w") as f:
|
||
f.write("# y, xstart, xend, rotation, color, label, va, bed\n")
|
||
f.write(f"0.75, .08, .92, 0, {palette[0]}, {target}, top, {target}.bed\n")
|
||
f.write(f"0.25, .08, .92, 0, {palette[1]}, {ref}, bottom, {ref}.bed\n")
|
||
f.write("# edges\n")
|
||
f.write(f"e, 0, 1, {edge_file}\n")
|
||
|
||
write_layout(layout_file, use_marked=False)
|
||
write_layout(layout_mark_file, use_marked=True)
|
||
|
||
final_pdf = output_dir / f"{prefix}.{pair_key}.interspecies_synteny.pdf"
|
||
final_png = output_dir / f"{prefix}.{pair_key}.interspecies_synteny.png"
|
||
final_mark_pdf = output_dir / f"{prefix}.{pair_key}.interspecies_synteny.marked.pdf"
|
||
final_mark_png = output_dir / f"{prefix}.{pair_key}.interspecies_synteny.marked.png"
|
||
base_result = run_plot(seqids_file, layout_file, final_pdf, final_png)
|
||
mark_result = run_plot(seqids_file, layout_mark_file, final_mark_pdf, final_mark_png)
|
||
success = final_pdf.exists() or final_png.exists()
|
||
pair_outputs.append({
|
||
"pair": pair_key,
|
||
"target_species": target,
|
||
"ref_species": ref,
|
||
"success": success,
|
||
"pdf_file": str(final_pdf) if final_pdf.exists() else "",
|
||
"png_file": str(final_png) if final_png.exists() else "",
|
||
"marked_pdf_file": str(final_mark_pdf) if final_mark_pdf.exists() else "",
|
||
"marked_png_file": str(final_mark_png) if final_mark_png.exists() else "",
|
||
"seqids_file": str(seqids_file),
|
||
"layout_file": str(layout_file),
|
||
"layout_mark_file": str(layout_mark_file),
|
||
"base_plot": base_result,
|
||
"marked_plot": mark_result,
|
||
})
|
||
|
||
return {
|
||
"success": any(item.get("success") for item in pair_outputs),
|
||
"pairs": pair_outputs,
|
||
"pairwise_plot_count": sum(1 for item in pair_outputs if item.get("success")),
|
||
}
|
||
|
||
|
||
def write_report(workspace: Path, prefix: str, metrics: Dict, parameters: Dict,
|
||
tool_versions: Dict, start_time: datetime, end_time: datetime,
|
||
elapsed_sec: float, cmd: List[str]) -> Path:
|
||
"""写入报告"""
|
||
report_path = workspace / "final" / f"{prefix}.interspecies_synteny_report.md"
|
||
|
||
# 收集可复现性信息
|
||
reproducibility_info = []
|
||
reproducibility_info.append("## 可复现性信息\n\n")
|
||
reproducibility_info.append("### 执行环境\n")
|
||
reproducibility_info.append(f"- **分析时间**: {start_time.isoformat()}\n")
|
||
reproducibility_info.append(f"- **完成时间**: {end_time.isoformat()}\n")
|
||
reproducibility_info.append(f"- **运行时长**: {elapsed_sec:.2f} 秒\n")
|
||
reproducibility_info.append(f"- **工作目录**: {workspace}\n")
|
||
reproducibility_info.append(f"- **输入目录**: {parameters.get('input_dir', 'N/A')}\n\n")
|
||
|
||
reproducibility_info.append("### 工具版本\n")
|
||
for tool, version in tool_versions.items():
|
||
reproducibility_info.append(f"- **{tool}**: {version}\n")
|
||
reproducibility_info.append("\n")
|
||
|
||
reproducibility_info.append("### 执行命令\n")
|
||
reproducibility_info.append(f"```bash\n{' '.join(cmd)}\n```\n\n")
|
||
|
||
reproducibility_info.append("### 输入参数\n")
|
||
for key, value in parameters.items():
|
||
if key != 'input_dir':
|
||
reproducibility_info.append(f"- **{key}**: {value}\n")
|
||
reproducibility_info.append("\n")
|
||
|
||
with open(report_path, "w") as f:
|
||
f.write(f"# 物种间共线性分析 - {prefix}\n\n")
|
||
f.write(f"**时间**: {datetime.now().isoformat()}\n\n")
|
||
f.write(f"- 目标物种 (研究对象): {parameters.get('target_species')}\n")
|
||
f.write(f"- 参考物种 (对照): {parameters.get('ref_species')}\n")
|
||
f.write(f"- 目标蛋白文件: {parameters.get('target_protein_spl')}\n")
|
||
f.write(f"- 参考蛋白文件: {parameters.get('ref_protein_spl')}\n")
|
||
f.write(f"- 目标全蛋白组: {parameters.get('target_protein_all')}\n")
|
||
f.write(f"- 参考全蛋白组: {parameters.get('ref_protein_all')}\n")
|
||
f.write(f"- BLAST engine requested: {parameters.get('blast_engine_requested')}\n")
|
||
f.write(f"- BLAST engine used: {metrics.get('blast_engine_used')}\n")
|
||
f.write(f"- BLAST fallback count: {metrics.get('blast_fallback_count', 0)}\n")
|
||
species_positions = metrics.get("species_position_results") or {}
|
||
if species_positions:
|
||
f.write("- 染色体背景: 保留主核染色体/染色体尺度序列,过滤疑似细胞器和短 contig/scaffold;无法识别主序列时回退全量。\n")
|
||
for species, info in species_positions.items():
|
||
f.write(
|
||
f" - {species}: plotted {info.get('chromosomes_plotted', 'N/A')}/"
|
||
f"{info.get('chromosomes_total', 'N/A')} seqids; "
|
||
f"filtered genes {info.get('genes_filtered_by_chromosome', 'N/A')} "
|
||
f"({info.get('chromosomes_filter_mode', 'N/A')})\n"
|
||
)
|
||
f.write("\n")
|
||
f.writelines(reproducibility_info)
|
||
return report_path
|
||
|
||
|
||
def get_file_info(file_path: Path) -> Dict[str, Any]:
|
||
"""获取文件的详细信息,用于artifact规范"""
|
||
info = {
|
||
"size": 0,
|
||
"sha256": "",
|
||
"mtime": 0
|
||
}
|
||
if file_path.exists():
|
||
info["size"] = file_path.stat().st_size
|
||
info["mtime"] = file_path.stat().st_mtime
|
||
info["sha256"] = compute_file_hash(file_path)
|
||
return info
|
||
|
||
|
||
def write_manifest(workspace: Path, job_id: str, run_id: str, prefix: str,
|
||
start_time: datetime, end_time: datetime, artifacts: Dict,
|
||
metrics: Dict, parameters: Dict, tool_versions: Dict,
|
||
workspace_root: Path, input_dir: Path, cmd: List[str],
|
||
status: str = "success") -> Path:
|
||
"""写入manifest - 符合schema v1标准"""
|
||
elapsed_sec = (end_time - start_time).total_seconds()
|
||
|
||
# 构建符合规范的artifacts结构
|
||
formatted_artifacts = {}
|
||
for key, value in artifacts.items():
|
||
if isinstance(value, dict):
|
||
# 已经是字典格式,确保包含必需字段
|
||
formatted_artifacts[key] = value
|
||
elif isinstance(value, str):
|
||
# 字符串格式,转换为artifact对象
|
||
file_path = workspace / value
|
||
file_info = get_file_info(file_path)
|
||
formatted_artifacts[key] = {
|
||
"artifact": key,
|
||
"path": value,
|
||
"full_path": str(file_path.absolute()),
|
||
"type": "file",
|
||
"size": file_info["size"],
|
||
"sha256": file_info["sha256"],
|
||
"mtime": file_info["mtime"]
|
||
}
|
||
|
||
formatted_artifacts = rebase_manifest_payload_paths(workspace, formatted_artifacts)
|
||
rebased_metrics = rebase_manifest_payload_paths(workspace, metrics)
|
||
rebased_parameters = rebase_manifest_payload_paths(workspace, parameters)
|
||
|
||
manifest_data = {
|
||
"manifest_version": "1.0",
|
||
"job_id": job_id,
|
||
"stage": "interspecies_synteny",
|
||
"run_id": run_id,
|
||
"prefix": prefix,
|
||
"timestamp": end_time.isoformat(),
|
||
"status": status,
|
||
"start_time": start_time.isoformat(),
|
||
"end_time": end_time.isoformat(),
|
||
"elapsed_sec": round(elapsed_sec, 2),
|
||
"cmd": rebase_manifest_payload_paths(workspace, cmd),
|
||
"workspace_root": str(workspace_root.absolute()),
|
||
"workspace": str(workspace.absolute()),
|
||
"input_dir": str(input_dir.absolute()),
|
||
"tool_versions": tool_versions,
|
||
"artifacts": formatted_artifacts,
|
||
"parameters": rebased_parameters,
|
||
"metrics": rebased_metrics
|
||
}
|
||
for key in (
|
||
"scope_id",
|
||
"scope_mode",
|
||
"scope_file",
|
||
"target_species",
|
||
"reference_species",
|
||
"comparative_species",
|
||
"selected_species",
|
||
"family",
|
||
"analysis_route",
|
||
"member_set_id",
|
||
):
|
||
if key in rebased_parameters:
|
||
manifest_data[key] = rebased_parameters[key]
|
||
|
||
if rebased_parameters.get("scope_id"):
|
||
scope_context_data = {
|
||
"scope_id": rebased_parameters.get("scope_id", ""),
|
||
"scope_mode": rebased_parameters.get("scope_mode", ""),
|
||
"family": rebased_parameters.get("family", ""),
|
||
"target_species": rebased_parameters.get("target_species", []),
|
||
"reference_species": rebased_parameters.get("reference_species", []),
|
||
"comparative_species": rebased_parameters.get("comparative_species", []),
|
||
"selected_species": rebased_parameters.get("selected_species", []),
|
||
}
|
||
context = build_stage_context(
|
||
stage="interspecies_synteny",
|
||
prefix=prefix,
|
||
scope_data=scope_context_data,
|
||
scope_file=rebased_parameters.get("scope_file", ""),
|
||
output_prefix=prefix,
|
||
analysis_route=rebased_parameters.get("analysis_route", ""),
|
||
member_set_id=rebased_parameters.get("member_set_id", ""),
|
||
)
|
||
if context:
|
||
manifest_data = apply_stage_context(manifest_data, context)
|
||
|
||
manifest_path = workspace / "final" / f"{prefix}.interspecies_synteny.manifest.json"
|
||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(manifest_path, "w", encoding='utf-8') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
if rebased_parameters.get("scope_id"):
|
||
scoped_manifest_path = workspace / "final" / f"{prefix}.{sanitize_token(rebased_parameters.get('scope_id', ''))}.interspecies_synteny.manifest.json"
|
||
manifest_data["scope_manifest"] = str(scoped_manifest_path.absolute())
|
||
with open(scoped_manifest_path, "w", encoding='utf-8') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
|
||
return manifest_path
|
||
|
||
|
||
|
||
def _clean_bool(value: Any) -> bool:
|
||
return str(value).strip().lower() in {"true", "1", "yes", "y"}
|
||
|
||
|
||
def _clean_text(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
try:
|
||
import pandas as pd
|
||
if pd.isna(value):
|
||
return ""
|
||
except Exception:
|
||
pass
|
||
text = str(value).strip()
|
||
return "" if text.lower() in {"nan", "none", "null", "undefined", "-"} else text
|
||
|
||
|
||
def _gene_family_from_prefix(prefix: str) -> str:
|
||
return prefix.split('.', 1)[1] if '.' in prefix else prefix
|
||
|
||
|
||
def _scope_file_record(workspace: Path, input_dir: Path, scope_data: Dict[str, Any], species: str, file_type: str) -> Optional[Path]:
|
||
for record in ((scope_data.get("files") or {}).get(species) or {}).get(file_type, []) or []:
|
||
path_text = record.get("path") or record.get("full_path") or ""
|
||
if path_text:
|
||
path = rebase_current_job_path(workspace, path_text, input_dir=input_dir) or Path(path_text)
|
||
if path.exists():
|
||
return path
|
||
return None
|
||
|
||
|
||
def _add_scope_species_to_groups(workspace: Path, input_dir: Path, groups: Dict[str, Any], scope_data: Dict[str, Any]) -> None:
|
||
family = scope_data.get("family") or groups.get("gene_family") or ""
|
||
target_set = set(scope_data.get("target_species") or [])
|
||
reference_set = set(scope_data.get("reference_species") or [])
|
||
comparative_set = set(scope_data.get("comparative_species") or [])
|
||
for species in target_set | reference_set | comparative_set:
|
||
group_name = "target" if species in target_set else "ref"
|
||
item = groups[group_name].setdefault(species, {"species": species, "gene_family": family, "files": {}})
|
||
item["gene_family"] = item.get("gene_family") or family
|
||
item["role"] = "target" if species in target_set else ("reference" if species in reference_set else "comparative")
|
||
files = item.setdefault("files", {})
|
||
for file_type in ("pep", "family_pep", "gff"):
|
||
path = _scope_file_record(workspace, input_dir, scope_data, species, file_type)
|
||
if path:
|
||
key = "all_pep" if file_type == "pep" else file_type
|
||
files[key] = str(path)
|
||
|
||
|
||
def _apply_scope_selection_to_args(args, scope_data: Dict[str, Any]) -> None:
|
||
targets = [str(x).strip() for x in (scope_data.get("target_species") or []) if str(x).strip()]
|
||
selected = [str(x).strip() for x in (scope_data.get("selected_species") or []) if str(x).strip()]
|
||
refs = [str(x).strip() for x in (scope_data.get("reference_species") or []) if str(x).strip()]
|
||
comps = [str(x).strip() for x in (scope_data.get("comparative_species") or []) if str(x).strip()]
|
||
selected_ref_side = [sp for sp in selected if sp not in set(targets)]
|
||
if not selected_ref_side:
|
||
selected_ref_side = refs + comps
|
||
if targets and not getattr(args, "target_species", None):
|
||
args.target_species = ",".join(targets)
|
||
if selected_ref_side and not getattr(args, "ref_species", None):
|
||
args.ref_species = ",".join(dict.fromkeys(selected_ref_side))
|
||
|
||
|
||
def _resolve_path(path_text: str, input_dir: Path, workspace: Optional[Path] = None) -> Optional[Path]:
|
||
if not path_text:
|
||
return None
|
||
if workspace:
|
||
rebased = rebase_current_job_path(workspace, path_text, input_dir=input_dir)
|
||
if rebased:
|
||
return rebased
|
||
path = Path(path_text)
|
||
if path.is_absolute():
|
||
return path if path.exists() else None
|
||
for candidate in (input_dir / path, input_dir / path.name):
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def _read_config_groups(input_dir: Path, default_family: str) -> Dict[str, Any]:
|
||
config_path = input_dir / "config.xlsx"
|
||
groups = {"target": {}, "ref": {}, "seed_reference": {}, "gene_family": default_family}
|
||
if not config_path.exists():
|
||
return groups
|
||
try:
|
||
import pandas as pd
|
||
df = pd.read_excel(config_path)
|
||
except Exception as exc:
|
||
print(f"[WARN] Failed to read config.xlsx: {exc}")
|
||
return groups
|
||
for _, row in df.iterrows():
|
||
species = _clean_text(row.get("Species_Code", ""))
|
||
row_family = _clean_text(row.get("Gene_Family", ""))
|
||
family = row_family or default_family
|
||
raw_file_type = _clean_text(row.get("File_Type", "")).lower()
|
||
file_scope = _clean_text(row.get("File_Scope", "")).lower()
|
||
file_type = raw_file_type
|
||
original_name = _clean_text(row.get("Original_File_Name", ""))
|
||
role = _clean_text(row.get("Species_Role", "")).lower()
|
||
if not species:
|
||
continue
|
||
if role in {"seed", "seed_reference", "known_family_reference"}:
|
||
group_name = "seed_reference"
|
||
elif role in {"comparative", "comparison", "outgroup", "reference", "ref", "comparison_reference"}:
|
||
group_name = "ref"
|
||
else:
|
||
group_name = "ref" if _clean_bool(row.get("Ref", False)) else "target"
|
||
item = groups[group_name].setdefault(species, {"species": species, "gene_family": family, "files": {}})
|
||
item["gene_family"] = family
|
||
item["role"] = role or group_name
|
||
if raw_file_type in {"pep", "protein"}:
|
||
if file_scope in {"known_family", "family", "seed"} or group_name == "seed_reference":
|
||
file_type = "family_pep"
|
||
else:
|
||
file_type = "all_pep"
|
||
if file_type:
|
||
item["files"][file_type] = original_name
|
||
if row_family:
|
||
groups["gene_family"] = row_family
|
||
return groups
|
||
|
||
|
||
def _read_genome_prepare_artifacts(workspace: Path, input_dir: Path, prefix: str) -> Dict[str, Dict[str, Path]]:
|
||
result: Dict[str, Dict[str, Path]] = {}
|
||
for manifest_path in (workspace / "final" / f"{prefix}.genome_prepare.manifest.json", input_dir / "final" / f"{prefix}.genome_prepare.manifest.json"):
|
||
if not manifest_path.exists():
|
||
continue
|
||
try:
|
||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
print(f"[WARN] Failed to read genome_prepare manifest {manifest_path}: {exc}")
|
||
continue
|
||
for key, artifact in (data.get("artifacts") or {}).items():
|
||
kind = artifact.get("artifact")
|
||
if "_ref_" in key:
|
||
species = key.split("_ref_", 1)[0]
|
||
else:
|
||
species = key.split("_", 1)[0]
|
||
found = _resolve_path(artifact.get("full_path") or artifact.get("path") or "", input_dir, workspace=workspace)
|
||
if found and kind:
|
||
result.setdefault(species, {})[kind] = found
|
||
return result
|
||
|
||
|
||
def _find_species_file(input_dir: Path, species: str, family: str, file_type: str, original_name: str = "") -> Optional[Path]:
|
||
if file_type == "all_pep":
|
||
patterns = [
|
||
f"{species}.all_pep.fasta", f"{species}.pep.fasta", f"{species}.protein.fasta", f"{species}.faa",
|
||
f"{species}.{family}.all_pep.fasta", f"{species}.{family}.all_pep.fa",
|
||
f"{species}.{family}.protein.fasta", f"{species}.{family}.faa",
|
||
f"{species}.{family}.pep.fasta", f"{species}.{family}.pep.fa",
|
||
]
|
||
elif file_type == "family_pep":
|
||
patterns = [
|
||
f"{species}.{family}.family_pep.fasta", f"{species}.{family}.family_pep.fa",
|
||
f"{species}.{family}.pep.fasta", f"{species}.{family}.pep.fa",
|
||
]
|
||
elif file_type == "pep":
|
||
patterns = [
|
||
f"{species}.{family}.pep.fasta", f"{species}.{family}.pep.fa",
|
||
f"{species}.{family}.protein.fasta", f"{species}.{family}.faa",
|
||
f"{species}.pep.fasta", f"{species}.pep.fa", f"{species}.protein.fasta", f"{species}.faa",
|
||
]
|
||
elif file_type == "gff":
|
||
patterns = [
|
||
f"{species}.{family}_final.gff3", f"{species}.{family}.gff3", f"{species}.{family}.gff",
|
||
f"{species}_final.gff3", f"{species}.gff3", f"{species}.gff",
|
||
]
|
||
else:
|
||
patterns = []
|
||
if original_name:
|
||
patterns.append(Path(original_name).name)
|
||
for pattern in patterns:
|
||
for candidate in (input_dir / pattern, input_dir / species / pattern):
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def _find_species_family_pep(
|
||
workspace: Path,
|
||
input_dir: Path,
|
||
species: str,
|
||
family: str,
|
||
original_name: str = "",
|
||
) -> Optional[Path]:
|
||
"""Resolve per-species family protein FASTA for interspecies tree building."""
|
||
found = _find_species_file(input_dir, species, family, "family_pep", original_name)
|
||
if found:
|
||
return found
|
||
for candidate in (
|
||
workspace / "final" / f"{species}.{family}.final_members.pep.fa",
|
||
workspace / "final" / f"{species}.{family}.curated_members.pep.fa",
|
||
input_dir / "final" / f"{species}.{family}.final_members.pep.fa",
|
||
input_dir / f"{species}.{family}.pep.fa",
|
||
input_dir / f"{species}.{family}.pep.fasta",
|
||
):
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def _parse_species_selection(value: Optional[str]) -> List[str]:
|
||
if not value:
|
||
return []
|
||
return [item.strip() for item in str(value).split(',') if item.strip()]
|
||
|
||
|
||
def _filter_group_by_selection(group: Dict[str, Any], selected: List[str]) -> Dict[str, Any]:
|
||
if not selected:
|
||
return group
|
||
selected_set = set(selected)
|
||
return {species: item for species, item in group.items() if species in selected_set}
|
||
|
||
|
||
def _species_default_selection(available: List[str], preferred_ref: bool = False) -> List[str]:
|
||
if not available:
|
||
return []
|
||
if preferred_ref:
|
||
for preferred in ("at", "ath"):
|
||
for species in available:
|
||
if species.lower() == preferred:
|
||
return [species]
|
||
return [available[0]]
|
||
return list(available)
|
||
|
||
|
||
def _gene_matches_species(gene_id: str, species: str, species_id_sets: Dict[str, set]) -> bool:
|
||
variants = id_variants(gene_id)
|
||
if species in species_id_sets and variants & species_id_sets[species]:
|
||
return True
|
||
return normalize_seq_id(gene_id).lower().startswith(species.lower())
|
||
|
||
|
||
def _filter_fasta_by_species(source: Path, output_file: Path, selected_species: List[str], species_id_sets: Dict[str, set]) -> Path:
|
||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||
if not selected_species:
|
||
shutil.copy(source, output_file)
|
||
return output_file
|
||
keep = False
|
||
written = 0
|
||
with open_text(source, "rt") as f_in, open(output_file, "w") as f_out:
|
||
for line in f_in:
|
||
if line.startswith(">"):
|
||
gene_id = normalize_seq_id(line[1:])
|
||
keep = any(_gene_matches_species(gene_id, species, species_id_sets) for species in selected_species)
|
||
if keep:
|
||
written += 1
|
||
f_out.write(line)
|
||
elif keep:
|
||
f_out.write(line)
|
||
if written == 0:
|
||
raise RuntimeError(f"Selected species {selected_species} produced an empty filtered FASTA from {source}")
|
||
return output_file
|
||
|
||
|
||
def discover_group_inputs(workspace: Path, input_dir: Path, prefix: str, args) -> Dict[str, Any]:
|
||
family = _gene_family_from_prefix(prefix)
|
||
groups = _read_config_groups(input_dir, family)
|
||
scope_data = getattr(args, "scope_data", {}) or {}
|
||
if scope_data:
|
||
groups["gene_family"] = scope_data.get("family") or groups.get("gene_family") or family
|
||
_add_scope_species_to_groups(workspace, input_dir, groups, scope_data)
|
||
# Interspecies synteny compares target species against the reference side.
|
||
# Known-family seed references such as At.SPL are valid reference inputs even
|
||
# when config marks them as seed_reference instead of comparative/ref.
|
||
for species, item in (groups.get("seed_reference") or {}).items():
|
||
groups["ref"].setdefault(species, item)
|
||
artifacts = _read_genome_prepare_artifacts(workspace, input_dir, prefix)
|
||
family = groups.get("gene_family") or family
|
||
|
||
available_target_species = list(groups["target"].keys())
|
||
available_ref_species = list(groups["ref"].keys())
|
||
requested_targets = _parse_species_selection(getattr(args, "target_species", None))
|
||
requested_refs = _parse_species_selection(getattr(args, "ref_species", None))
|
||
selected_targets = requested_targets or _species_default_selection(available_target_species, preferred_ref=False)
|
||
selected_refs = requested_refs or _species_default_selection(available_ref_species, preferred_ref=True)
|
||
|
||
unknown_targets = [sp for sp in selected_targets if sp not in groups["target"]]
|
||
unknown_refs = [sp for sp in selected_refs if sp not in groups["ref"]]
|
||
if unknown_targets:
|
||
raise RuntimeError(f"Selected target species not found in config Ref=False group: {', '.join(unknown_targets)}")
|
||
if unknown_refs:
|
||
raise RuntimeError(f"Selected reference species not found in config Ref=True group: {', '.join(unknown_refs)}")
|
||
if not selected_targets:
|
||
raise RuntimeError("No target species selected for interspecies analysis")
|
||
if not selected_refs:
|
||
raise RuntimeError("No reference species selected for interspecies analysis")
|
||
|
||
groups["target"] = _filter_group_by_selection(groups["target"], selected_targets)
|
||
groups["ref"] = _filter_group_by_selection(groups["ref"], selected_refs)
|
||
|
||
for group_name in ("target", "ref"):
|
||
for species, item in groups[group_name].items():
|
||
files = item.setdefault("resolved", {})
|
||
artifact_paths = artifacts.get(species, {})
|
||
original_files = item.get("files", {})
|
||
item_family = item.get("gene_family", family)
|
||
all_original = original_files.get("all_pep", "") or original_files.get("protein", "")
|
||
family_original = original_files.get("family_pep", "")
|
||
legacy_pep_original = original_files.get("pep", "")
|
||
if group_name == "ref" and legacy_pep_original and not family_original:
|
||
family_original = legacy_pep_original
|
||
if group_name == "target" and legacy_pep_original and not all_original:
|
||
all_original = legacy_pep_original
|
||
scope_all_pep = _scope_file_record(workspace, input_dir, scope_data, species, "pep") if scope_data else None
|
||
scope_family_pep = _scope_file_record(workspace, input_dir, scope_data, species, "family_pep") if scope_data else None
|
||
scope_gff = _scope_file_record(workspace, input_dir, scope_data, species, "gff") if scope_data else None
|
||
prepared_all_pep = (
|
||
_find_species_file(input_dir, species, item_family, "all_pep", all_original) or
|
||
artifact_paths.get("all_pep") or artifact_paths.get("ref_all_pep") or artifact_paths.get("pep")
|
||
)
|
||
files["all_pep"] = prepared_all_pep or scope_all_pep
|
||
files["family_pep"] = (
|
||
scope_family_pep or
|
||
_find_species_family_pep(workspace, input_dir, species, item_family, family_original) or
|
||
artifact_paths.get("family_pep") or artifact_paths.get("ref_family_pep") or artifact_paths.get("ref_pep")
|
||
)
|
||
files["pep"] = files.get("all_pep") or files.get("family_pep")
|
||
files["gff"] = (
|
||
artifact_paths.get("gff") or artifact_paths.get("ref_gff") or
|
||
_find_species_file(input_dir, species, item_family, "gff", original_files.get("gff", "")) or
|
||
scope_gff
|
||
)
|
||
|
||
analysis_dir = workspace / "interspecies_synteny" / "analysis"
|
||
analysis_dir.mkdir(parents=True, exist_ok=True)
|
||
species_id_sets = collect_species_id_sets(groups)
|
||
|
||
raw_target_family_protein = Path(args.target_protein) if args.target_protein else None
|
||
if raw_target_family_protein is None:
|
||
target_peps = [item.get("resolved", {}).get("family_pep") for item in groups["target"].values() if item.get("resolved", {}).get("family_pep")]
|
||
if target_peps:
|
||
raw_target_family_protein = analysis_dir / f"Target_selected.{family}.pep.fasta"
|
||
merge_many_fastas(target_peps, raw_target_family_protein)
|
||
else:
|
||
raw_target_family_protein = workspace / "final" / f"{prefix}.final_members.pep.fa"
|
||
target_family_protein = raw_target_family_protein
|
||
if raw_target_family_protein and raw_target_family_protein.exists():
|
||
selected_target_output = analysis_dir / f"{prefix}.selected_target.family.pep.fa"
|
||
prefix_species = prefix.split(".", 1)[0] if "." in prefix else ""
|
||
single_species_family_fasta = (
|
||
len(selected_targets) == 1
|
||
and prefix_species == selected_targets[0]
|
||
and raw_target_family_protein.name == f"{prefix}.final_members.pep.fa"
|
||
)
|
||
if single_species_family_fasta:
|
||
# Species-specific Identify output may use transcript IDs while the
|
||
# standardized whole-proteome ID set uses protein IDs. It is already
|
||
# the selected species, so do not split it again by species IDs.
|
||
shutil.copy2(raw_target_family_protein, selected_target_output)
|
||
target_family_protein = selected_target_output
|
||
else:
|
||
target_family_protein = _filter_fasta_by_species(
|
||
raw_target_family_protein,
|
||
selected_target_output,
|
||
selected_targets,
|
||
species_id_sets,
|
||
)
|
||
|
||
ref_family_protein = Path(args.ref_protein) if args.ref_protein else None
|
||
if ref_family_protein is None:
|
||
ref_peps = [item.get("resolved", {}).get("family_pep") for item in groups["ref"].values() if item.get("resolved", {}).get("family_pep")]
|
||
if ref_peps:
|
||
ref_family_protein = analysis_dir / f"Ref_selected.{family}.pep.fasta"
|
||
merge_many_fastas(ref_peps, ref_family_protein)
|
||
else:
|
||
for candidate in [input_dir / f"Ref_combined.{family}.pep.fasta", input_dir / f"Ref_combined.{family}.pep.fa", input_dir / "Ref_combined.pep.fasta"]:
|
||
if candidate.exists():
|
||
ref_family_protein = candidate
|
||
break
|
||
if ref_family_protein is None and raw_target_family_protein and raw_target_family_protein.exists() and selected_refs:
|
||
# Comparative species often have no curated family FASTA. If identify was
|
||
# run on their whole proteomes, split their family members from the same
|
||
# final_members file used for targets.
|
||
try:
|
||
ref_family_protein = _filter_fasta_by_species(
|
||
raw_target_family_protein,
|
||
analysis_dir / f"{prefix}.selected_ref.family.pep.fa",
|
||
selected_refs,
|
||
species_id_sets,
|
||
)
|
||
except Exception as exc:
|
||
print(f"[WARN] Could not derive reference family proteins from final_members: {exc}")
|
||
ref_family_protein = None
|
||
selected_ref_output = analysis_dir / f"{prefix}.selected_ref.family.pep.fa"
|
||
if ref_family_protein and Path(ref_family_protein).exists() and Path(ref_family_protein).resolve() != selected_ref_output.resolve():
|
||
ref_family_protein = _filter_fasta_by_species(
|
||
Path(ref_family_protein),
|
||
selected_ref_output,
|
||
selected_refs,
|
||
species_id_sets,
|
||
)
|
||
|
||
target_all_peps = {sp: item.get("resolved", {}).get("all_pep") for sp, item in groups["target"].items() if item.get("resolved", {}).get("all_pep")}
|
||
ref_all_peps = {sp: item.get("resolved", {}).get("all_pep") for sp, item in groups["ref"].items() if item.get("resolved", {}).get("all_pep")}
|
||
target_gffs = {sp: item.get("resolved", {}).get("gff") for sp, item in groups["target"].items() if item.get("resolved", {}).get("gff")}
|
||
ref_gffs = {sp: item.get("resolved", {}).get("gff") for sp, item in groups["ref"].items() if item.get("resolved", {}).get("gff")}
|
||
|
||
return {
|
||
"gene_family": family,
|
||
"target_species": selected_targets,
|
||
"ref_species": selected_refs,
|
||
"available_target_species": available_target_species,
|
||
"available_ref_species": available_ref_species,
|
||
"selected_target_species": selected_targets,
|
||
"selected_ref_species": selected_refs,
|
||
"target_family_protein": target_family_protein,
|
||
"ref_family_protein": ref_family_protein,
|
||
"target_all_peps": target_all_peps,
|
||
"ref_all_peps": ref_all_peps,
|
||
"target_gffs": target_gffs,
|
||
"ref_gffs": ref_gffs,
|
||
"groups": groups,
|
||
"scope": scope_data,
|
||
}
|
||
|
||
|
||
def merge_many_fastas(files: List[Path], output_file: Path) -> Path:
|
||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(output_file, "w") as out:
|
||
for fasta in files:
|
||
with open_text(fasta, "rt") as f:
|
||
out.write(f.read().rstrip() + "\n")
|
||
return output_file
|
||
|
||
|
||
def _parse_gff_attr(attr: str) -> Dict[str, str]:
|
||
result = {}
|
||
for item in attr.strip().strip(';').split(';'):
|
||
item = item.strip()
|
||
if not item:
|
||
continue
|
||
if '=' in item:
|
||
k, v = item.split('=', 1)
|
||
elif ' ' in item:
|
||
k, v = item.split(' ', 1)
|
||
else:
|
||
continue
|
||
result[k.strip()] = v.strip().strip('"')
|
||
return result
|
||
|
||
|
||
def _is_primary_chromosome_name(chrom: str) -> bool:
|
||
name = str(chrom or "").strip()
|
||
if re.fullmatch(r"\d+", name):
|
||
return True
|
||
return bool(re.fullmatch(r"chr(?:omosome)?[\s_.-]*\d+[A-Za-z]?", name, flags=re.IGNORECASE))
|
||
|
||
|
||
def _is_obvious_non_nuclear_sequence(chrom: str) -> bool:
|
||
name = str(chrom or "").strip().lower()
|
||
if re.search(r"(chloroplast|plastid|mitochond|mitoch|scaffold|contig|unplaced|unlocalized|random)", name):
|
||
return True
|
||
return bool(re.fullmatch(r"(chrm|chrmt|mt|mito|chrc|chrpt|pt|cp)", name))
|
||
|
||
|
||
def _select_primary_chromosomes_from_lengths(chrom_lengths: Dict[str, int]) -> Tuple[set, Dict[str, Any]]:
|
||
"""Select nuclear chromosome-scale records while falling back on unusual assemblies."""
|
||
records = [(chrom, int(length)) for chrom, length in chrom_lengths.items() if chrom and int(length) > 0]
|
||
total = len(records)
|
||
if not records:
|
||
return set(), {
|
||
"chromosomes_total": 0,
|
||
"chromosomes_plotted": 0,
|
||
"chromosomes_filtered": 0,
|
||
"chromosomes_filter_mode": "empty",
|
||
"chromosomes_filtered_names": [],
|
||
}
|
||
|
||
selected: List[Tuple[str, int]]
|
||
mode: str
|
||
named_primary = [(chrom, length) for chrom, length in records if _is_primary_chromosome_name(chrom)]
|
||
if named_primary:
|
||
selected = named_primary
|
||
mode = "primary_name"
|
||
else:
|
||
nuclear_candidates = [
|
||
(chrom, length)
|
||
for chrom, length in records
|
||
if not _is_obvious_non_nuclear_sequence(chrom)
|
||
]
|
||
length_source = nuclear_candidates or records
|
||
sorted_lengths = sorted((length for _, length in length_source), reverse=True)
|
||
median_length = sorted_lengths[len(sorted_lengths) // 2]
|
||
max_length = sorted_lengths[0]
|
||
threshold = max(1_000_000, int(median_length * 0.10), int(max_length * 0.02))
|
||
selected = [(chrom, length) for chrom, length in length_source if length >= threshold]
|
||
mode = "length_distribution"
|
||
|
||
if not selected:
|
||
selected = records
|
||
mode = "fallback_all_records"
|
||
|
||
selected_names = {chrom for chrom, _ in selected}
|
||
filtered_names = sorted(chrom for chrom, _ in records if chrom not in selected_names)
|
||
return selected_names, {
|
||
"chromosomes_total": total,
|
||
"chromosomes_plotted": len(selected_names),
|
||
"chromosomes_filtered": max(0, total - len(selected_names)),
|
||
"chromosomes_filter_mode": mode,
|
||
"chromosomes_filtered_names": filtered_names,
|
||
}
|
||
|
||
|
||
def _match_gff_id(attrs: Dict[str, str], protein_ids: set) -> Optional[str]:
|
||
candidates = [attrs.get("ID"), attrs.get("transcript_id"), attrs.get("Name"), attrs.get("Parent")]
|
||
for candidate in candidates:
|
||
if not candidate:
|
||
continue
|
||
for item in str(candidate).split(','):
|
||
norm = normalize_seq_id(item)
|
||
if norm in protein_ids:
|
||
return norm
|
||
for variant in id_variants(norm):
|
||
if variant in protein_ids:
|
||
return variant
|
||
return None
|
||
|
||
|
||
def build_species_position_files(species: str, gff3: Path, protein_fasta: Path, work_dir: Path) -> Dict[str, Any]:
|
||
"""Create filtered MCScanX simplified GFF and JCVI BED for one species."""
|
||
simple_gff = work_dir / f"{species}.gff"
|
||
bed_file = work_dir / f"{species}.bed"
|
||
protein_ids = fasta_id_set(protein_fasta)
|
||
seen = set()
|
||
records: List[Tuple[str, str, str, str]] = []
|
||
chrom_lengths: Dict[str, int] = {}
|
||
features_seen = 0
|
||
work_dir.mkdir(parents=True, exist_ok=True)
|
||
with open_text(gff3, "rt") as f_in:
|
||
for line in f_in:
|
||
if not line.strip() or line.startswith('#'):
|
||
continue
|
||
parts = line.rstrip('\n').split(' ')
|
||
if len(parts) < 9 or parts[2] not in {"mRNA", "transcript", "gene"}:
|
||
continue
|
||
features_seen += 1
|
||
attrs = _parse_gff_attr(parts[8])
|
||
gene_id = _match_gff_id(attrs, protein_ids)
|
||
if not gene_id or gene_id in seen:
|
||
continue
|
||
seen.add(gene_id)
|
||
chrom = parts[0]
|
||
start, end = parts[3], parts[4]
|
||
try:
|
||
chrom_lengths[chrom] = max(chrom_lengths.get(chrom, 0), int(float(end)))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
records.append((chrom, start, end, gene_id))
|
||
|
||
selected_chroms, filter_metrics = _select_primary_chromosomes_from_lengths(chrom_lengths)
|
||
if not selected_chroms:
|
||
selected_chroms = set(chrom_lengths)
|
||
filtered_records = [record for record in records if record[0] in selected_chroms]
|
||
if not filtered_records and records:
|
||
filtered_records = records
|
||
filter_metrics["chromosomes_filter_mode"] = "fallback_all_records"
|
||
filter_metrics["chromosomes_plotted"] = filter_metrics["chromosomes_total"]
|
||
filter_metrics["chromosomes_filtered"] = 0
|
||
filter_metrics["chromosomes_filtered_names"] = []
|
||
|
||
with open(simple_gff, "w") as gff_out, open(bed_file, "w") as bed_out:
|
||
for chrom, start, end, gene_id in filtered_records:
|
||
gff_out.write(f"{chrom} {gene_id} {start} {end}\n")
|
||
bed_out.write(f"{chrom} {start} {end} {gene_id}\n")
|
||
|
||
count = len(filtered_records)
|
||
filter_metrics["genes_before_chromosome_filter"] = len(records)
|
||
filter_metrics["genes_after_chromosome_filter"] = count
|
||
filter_metrics["genes_filtered_by_chromosome"] = max(0, len(records) - count)
|
||
return {
|
||
"success": count > 0,
|
||
"species": species,
|
||
"gff": simple_gff,
|
||
"bed": bed_file,
|
||
"gene_count": count,
|
||
"features_seen": features_seen,
|
||
"protein_id_count": len(protein_ids),
|
||
"error": "No GFF IDs matched protein FASTA IDs" if count == 0 else "",
|
||
**filter_metrics,
|
||
}
|
||
|
||
|
||
def collect_family_ids(target_family_fasta: Path, ref_family_fasta: Path, final_id_file: Optional[Path] = None) -> set:
|
||
ids = set()
|
||
for fasta in [target_family_fasta, ref_family_fasta]:
|
||
if fasta and Path(fasta).exists():
|
||
ids.update(fasta_id_set(Path(fasta)))
|
||
if final_id_file and final_id_file.exists():
|
||
with open(final_id_file, "r") as f:
|
||
for line in f:
|
||
if line.strip() and not line.startswith('#'):
|
||
ids.update(id_variants(line.strip().split()[0]))
|
||
return ids
|
||
|
||
|
||
def add_artifact(artifacts: Dict[str, Dict], key: str, file_path: Path, rel_path: str, artifact: str, file_type: str) -> None:
|
||
if not file_path or not file_path.exists():
|
||
return
|
||
info = get_file_info(file_path)
|
||
artifacts[key] = {
|
||
"artifact": artifact,
|
||
"path": rel_path,
|
||
"full_path": str(file_path.absolute()),
|
||
"type": file_type,
|
||
"size": info["size"],
|
||
"sha256": info["sha256"],
|
||
"mtime": info["mtime"],
|
||
}
|
||
|
||
|
||
def write_failure_manifest(workspace: Path, job_id: str, run_id: str, prefix: str, start_time: datetime,
|
||
workspace_root: Path, input_dir: Path, cmd: List[str], error: str,
|
||
scope_data: Optional[Dict[str, Any]] = None, scope_file: str = "",
|
||
analysis_route: str = "", member_set_id: str = "") -> Path:
|
||
end_time = datetime.now()
|
||
scope_data = scope_data or {}
|
||
parameters = {
|
||
"prefix": prefix,
|
||
"error": error,
|
||
"scope_file": scope_file,
|
||
"scope_id": scope_data.get("scope_id", ""),
|
||
"scope_mode": scope_data.get("scope_mode", ""),
|
||
"target_species": scope_data.get("target_species", []),
|
||
"reference_species": scope_data.get("reference_species", []),
|
||
"comparative_species": scope_data.get("comparative_species", []),
|
||
"selected_species": scope_data.get("selected_species", []),
|
||
"family": scope_data.get("family", ""),
|
||
}
|
||
if scope_data:
|
||
route = analysis_route or infer_analysis_route("interspecies_synteny", scope_data)
|
||
parameters["analysis_route"] = route
|
||
parameters["member_set_id"] = member_set_id or member_set_id_for(prefix, scope_data, route)
|
||
return write_manifest(
|
||
workspace, job_id, run_id, prefix, start_time, end_time,
|
||
artifacts={}, metrics={"error": error}, parameters=parameters,
|
||
tool_versions={}, workspace_root=workspace_root, input_dir=input_dir, cmd=cmd, status="failed"
|
||
)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="????????")
|
||
parser.add_argument("--prefix", required=True, help="?????? Multi.SPL")
|
||
parser.add_argument("--workspace", required=True)
|
||
parser.add_argument("--input-dir")
|
||
parser.add_argument("--ref-species", default=None, help="???????? config.xlsx ??")
|
||
parser.add_argument("--target-species", default=None, help="???????? config.xlsx ??")
|
||
parser.add_argument("--blast-engine", choices=["diamond", "blastp"], default="diamond", help="protein search engine for MCScanX pairwise BLAST file")
|
||
parser.add_argument("--plot-mode", choices=["multi", "pairwise", "both"], default="both", help="synteny plot outputs to generate: multi species, pairwise, or both")
|
||
parser.add_argument("--blast-evalue", default="1e-10", help="evalue cutoff for diamond/blastp search")
|
||
parser.add_argument("--max-target-seqs", type=int, default=5, help="maximum target sequences per query")
|
||
parser.add_argument("--ref-protein", help="?????????")
|
||
parser.add_argument("--target-protein", help="?????????")
|
||
parser.add_argument("--cpu", type=int, default=8)
|
||
parser.add_argument("--log-file", help="??????????????")
|
||
parser.add_argument("--workspace-root", help="??????????manifest?")
|
||
parser.add_argument("--reuse-tree", action="store_true", help="Reuse existing final interspecies tree if present")
|
||
parser.add_argument("--scope-file", help="Gene-family scope JSON from resolve_scope.py")
|
||
parser.add_argument("--analysis-route", help="Scoped analysis route recorded in manifest")
|
||
parser.add_argument("--member-set-id", help="Scoped member set identity recorded in manifest")
|
||
parser.add_argument("--tree-subfamily-arc-enabled", choices=["true", "false"], default="true",
|
||
help="Enable dynamic subfamily outer arcs in circular interspecies tree plots")
|
||
parser.add_argument("--tree-subfamily-arc-gap", type=float, default=1.2,
|
||
help="Extra gap between gene labels and circular subfamily arcs")
|
||
parser.add_argument("--tree-subfamily-arc-label-gap", type=float, default=0.6,
|
||
help="Extra gap between circular subfamily arcs and labels")
|
||
parser.add_argument("--tree-subfamily-arc-label-size", type=float, default=0.0,
|
||
help="Subfamily arc label size; 0 means automatic")
|
||
args = parser.parse_args()
|
||
|
||
start_time = datetime.now()
|
||
run_id = f"interspecies_synteny_{start_time.strftime('%Y%m%d_%H%M%S')}"
|
||
workspace = Path(args.workspace)
|
||
job_id = workspace.name
|
||
input_dir = Path(args.input_dir) if args.input_dir else workspace.parent.parent / "input" / job_id
|
||
workspace_root = Path(args.workspace_root) if args.workspace_root else workspace.parent
|
||
cmd = sys.argv
|
||
scope_data: Dict[str, Any] = {}
|
||
scope_file = ""
|
||
analysis_route = str(args.analysis_route or "").strip()
|
||
member_set_id = str(args.member_set_id or "").strip()
|
||
|
||
print("=" * 60)
|
||
print("Interspecies synteny analysis")
|
||
print("=" * 60)
|
||
|
||
try:
|
||
if args.scope_file:
|
||
scope_path = Path(args.scope_file)
|
||
if not scope_path.exists():
|
||
raise FileNotFoundError(f"Scope file not found: {args.scope_file}")
|
||
scope_file = str(scope_path.absolute())
|
||
scope_data = read_scope(scope_path)
|
||
args.scope_data = scope_data
|
||
_apply_scope_selection_to_args(args, scope_data)
|
||
if not analysis_route:
|
||
analysis_route = infer_analysis_route("interspecies_synteny", scope_data)
|
||
if not member_set_id and scope_data.get("scope_id") and analysis_route:
|
||
member_set_id = member_set_id_for(args.prefix, scope_data, analysis_route)
|
||
else:
|
||
args.scope_data = {}
|
||
paths = prepare_directories(workspace)
|
||
tool_versions = get_tool_versions()
|
||
discovery = discover_group_inputs(workspace, input_dir, args.prefix, args)
|
||
target_group = "Target_combined"
|
||
ref_group = "Ref_combined"
|
||
print(f"Target species: {', '.join(discovery['target_species']) or 'NONE'}")
|
||
print(f"Reference species: {', '.join(discovery['ref_species']) or 'NONE'}")
|
||
print(f"Target family protein: {discovery['target_family_protein']}")
|
||
print(f"Ref family protein: {discovery['ref_family_protein']}")
|
||
|
||
if not discovery["target_family_protein"] or not Path(discovery["target_family_protein"]).exists():
|
||
raise RuntimeError(f"Target family protein missing: {discovery['target_family_protein']}")
|
||
if not discovery["ref_family_protein"] or not Path(discovery["ref_family_protein"]).exists():
|
||
raise RuntimeError("Reference family protein missing; expected Ref_combined.<Family>.pep.fasta or Ref species pep files")
|
||
|
||
artifacts = {}
|
||
metrics = {
|
||
"target_species": discovery["target_species"],
|
||
"ref_species": discovery["ref_species"],
|
||
"available_target_species": discovery.get("available_target_species", discovery["target_species"]),
|
||
"available_ref_species": discovery.get("available_ref_species", discovery["ref_species"]),
|
||
"selected_target_species": discovery.get("selected_target_species", discovery["target_species"]),
|
||
"selected_ref_species": discovery.get("selected_ref_species", discovery["ref_species"]),
|
||
"tree_success": False,
|
||
"synteny_success": False,
|
||
"synteny_skipped_reason": "",
|
||
"blast_engine_requested": args.blast_engine,
|
||
"plot_mode": args.plot_mode,
|
||
"multi_plot_generated": False,
|
||
"pairwise_plot_count": 0,
|
||
"pairwise_plot_success": False,
|
||
"blast_engine_used": [],
|
||
"blast_fallback_count": 0,
|
||
}
|
||
|
||
print("\nMerging family proteins for tree...")
|
||
merged_fasta = paths["analysis_dir"] / f"{args.prefix}.target_ref.family.pep.fa"
|
||
merge_result = merge_protein_sequences(Path(discovery["target_family_protein"]), Path(discovery["ref_family_protein"]), merged_fasta)
|
||
if not merge_result.get("success"):
|
||
raise RuntimeError(f"Failed to merge family proteins: {merge_result.get('stderr')}")
|
||
metrics["target_family_count"] = merge_result.get("target_count")
|
||
metrics["ref_family_count"] = merge_result.get("ref_count")
|
||
metrics["tree_merged_sequence_count"] = (merge_result.get("target_count") or 0) + (merge_result.get("ref_count") or 0)
|
||
ref_family_sources = [
|
||
str(item.get("resolved", {}).get("family_pep"))
|
||
for item in discovery["groups"].get("ref", {}).values()
|
||
if item.get("resolved", {}).get("family_pep")
|
||
]
|
||
metrics["tree_ref_family_sources"] = ref_family_sources
|
||
|
||
print("\nBuilding interspecies tree...")
|
||
final_tree = paths["final_dir"] / f"{args.prefix}.interspecies_tree.nwk"
|
||
reuse_tree = bool(args.reuse_tree and final_tree.exists())
|
||
if reuse_tree:
|
||
merged_tip_count = sum(1 for line in open_text(merged_fasta, "rt") if line.startswith(">"))
|
||
existing_tip_count = len(_read_tree_tip_labels(final_tree))
|
||
if existing_tip_count != merged_tip_count:
|
||
print(
|
||
f"Existing tree tip count ({existing_tip_count}) != merged family count ({merged_tip_count}); rebuilding tree"
|
||
)
|
||
reuse_tree = False
|
||
if reuse_tree:
|
||
print(f"Reusing existing interspecies tree: {final_tree}")
|
||
tree_result = {"tree_file": final_tree}
|
||
else:
|
||
tree_result = build_phylogenetic_tree(merged_fasta, paths["analysis_dir"], args.prefix, args.cpu)
|
||
if not tree_result.get("tree_file"):
|
||
raise RuntimeError(f"Tree construction failed: {tree_result.get('error', 'unknown error')}")
|
||
if Path(tree_result["tree_file"]).resolve() != final_tree.resolve():
|
||
shutil.copy(Path(tree_result["tree_file"]), final_tree)
|
||
metrics["tree_success"] = True
|
||
if tree_result.get("iqtree_executable"):
|
||
metrics["iqtree_executable"] = tree_result.get("iqtree_executable")
|
||
if tree_result.get("command"):
|
||
metrics["iqtree_command"] = tree_result.get("command")
|
||
add_artifact(artifacts, "tree_nwk", final_tree, f"final/{args.prefix}.interspecies_tree.nwk", "tree_nwk", "tree")
|
||
|
||
gene_species_map = paths["analysis_dir"] / f"{args.prefix}.gene_to_species.tsv"
|
||
write_gene_species_map(Path(discovery["target_family_protein"]), Path(discovery["ref_family_protein"]), discovery["target_species"], discovery["ref_species"], gene_species_map, collect_species_id_sets(discovery["groups"]))
|
||
add_artifact(artifacts, "gene_to_species", gene_species_map, f"interspecies_synteny/analysis/{args.prefix}.gene_to_species.tsv", "gene_to_species", "tsv")
|
||
|
||
print("\nPlotting interspecies tree...")
|
||
target_subfamily_file = input_dir / f"{args.prefix}.geneID.subfamily"
|
||
if not target_subfamily_file.exists():
|
||
target_subfamily_file = workspace / "final" / f"{args.prefix}.geneID.subfamily"
|
||
ref_subfamily_file = input_dir / f"SPL_{discovery['ref_species'][0]}.subfamily" if discovery["ref_species"] else input_dir / "SPL_ref.subfamily"
|
||
if not ref_subfamily_file.exists():
|
||
candidates = sorted(input_dir.glob("SPL_*.subfamily")) + sorted(input_dir.glob("*.SPL.subfamily"))
|
||
ref_subfamily_file = candidates[0] if candidates else ref_subfamily_file
|
||
|
||
subfamily_candidates: List[Path] = [
|
||
target_subfamily_file,
|
||
ref_subfamily_file,
|
||
input_dir / f"{args.prefix}.target_plus_reference.geneID.subfamily",
|
||
workspace / "final" / f"{args.prefix}.target_plus_reference.geneID.subfamily",
|
||
]
|
||
for species_value in list(discovery.get("target_species") or []) + list(discovery.get("ref_species") or []):
|
||
species_stem = str(species_value).strip()
|
||
if not species_stem:
|
||
continue
|
||
subfamily_candidates.extend([
|
||
input_dir / f"{species_stem}.SPL.target_plus_reference.geneID.subfamily",
|
||
workspace / "final" / f"{species_stem}.SPL.target_plus_reference.geneID.subfamily",
|
||
input_dir / f"{species_stem}.SPL.geneID.subfamily",
|
||
workspace / "final" / f"{species_stem}.SPL.geneID.subfamily",
|
||
])
|
||
subfamily_candidates.extend(sorted((workspace / "final").glob(f"{species_stem}.*.geneID.subfamily"), key=lambda p: p.stat().st_mtime, reverse=True))
|
||
merged_subfamily_file = _merge_matching_subfamily_files(subfamily_candidates, final_tree, paths["analysis_dir"], args.prefix)
|
||
if merged_subfamily_file:
|
||
target_subfamily_file = merged_subfamily_file
|
||
ref_subfamily_file = None
|
||
|
||
tree_plot_result = plot_interspecies_tree(
|
||
final_tree,
|
||
paths["final_dir"],
|
||
args.prefix,
|
||
target_group,
|
||
ref_group,
|
||
target_subfamily_file if target_subfamily_file and target_subfamily_file.exists() else None,
|
||
ref_subfamily_file if ref_subfamily_file and ref_subfamily_file.exists() else None,
|
||
gene_species_map,
|
||
subfamily_arc_enabled=str(args.tree_subfamily_arc_enabled).lower() != "false",
|
||
subfamily_arc_gap=args.tree_subfamily_arc_gap,
|
||
subfamily_arc_label_gap=args.tree_subfamily_arc_label_gap,
|
||
subfamily_arc_label_size=args.tree_subfamily_arc_label_size,
|
||
)
|
||
if tree_plot_result.get("pdf_file"):
|
||
add_artifact(artifacts, "tree_pdf", tree_plot_result["pdf_file"], f"final/{args.prefix}.interspecies_tree.pdf", "tree_pdf", "plot")
|
||
if tree_plot_result.get("png_file"):
|
||
add_artifact(artifacts, "tree_png", tree_plot_result["png_file"], f"final/{args.prefix}.interspecies_tree.png", "tree_png", "plot")
|
||
if tree_plot_result.get("circular_pdf_file"):
|
||
add_artifact(artifacts, "tree_circular_pdf", tree_plot_result["circular_pdf_file"], f"final/{args.prefix}.interspecies_tree.circular_tree.pdf", "tree_circular_pdf", "plot")
|
||
if tree_plot_result.get("circular_png_file"):
|
||
add_artifact(artifacts, "tree_circular_png", tree_plot_result["circular_png_file"], f"final/{args.prefix}.interspecies_tree.circular_tree.png", "tree_circular_png", "plot")
|
||
|
||
synteny_plot_result = {"success": False}
|
||
pair_results: List[Dict[str, Any]] = []
|
||
species_position_results: Dict[str, Dict[str, Any]] = {}
|
||
|
||
missing = []
|
||
if not discovery["target_all_peps"]:
|
||
missing.append("target all protein")
|
||
if not discovery["ref_all_peps"]:
|
||
missing.append("reference all protein")
|
||
if not discovery["target_gffs"]:
|
||
missing.append("target GFF")
|
||
if not discovery["ref_gffs"]:
|
||
missing.append("reference GFF")
|
||
|
||
if not missing:
|
||
print("\nPreparing per-species GFF/BED files for pairwise MCScanX...")
|
||
all_species = discovery["target_species"] + discovery["ref_species"]
|
||
for species in all_species:
|
||
pep = discovery["target_all_peps"].get(species) or discovery["ref_all_peps"].get(species)
|
||
gff = discovery["target_gffs"].get(species) or discovery["ref_gffs"].get(species)
|
||
if not pep or not gff:
|
||
species_position_results[species] = {"success": False, "error": "missing pep or gff"}
|
||
continue
|
||
result = build_species_position_files(species, Path(gff), Path(pep), paths["analysis_dir"])
|
||
species_position_results[species] = result
|
||
if result.get("success"):
|
||
add_artifact(artifacts, f"{species}_mcscanx_gff", result["gff"], f"interspecies_synteny/analysis/{species}.gff", "mcscanx_gff", "gff")
|
||
add_artifact(artifacts, f"{species}_bed", result["bed"], f"interspecies_synteny/analysis/{species}.bed", "jcvi_bed", "bed")
|
||
print(
|
||
f" {species}: {result.get('gene_count', 0)} matched genes after chromosome filter "
|
||
f"({result.get('chromosomes_plotted', 'N/A')}/{result.get('chromosomes_total', 'N/A')} seqids plotted; "
|
||
f"filtered genes={result.get('genes_filtered_by_chromosome', 0)})"
|
||
)
|
||
|
||
print("\nRunning pairwise target-ref MCScanX...")
|
||
for target_species in discovery["target_species"]:
|
||
for ref_species in discovery["ref_species"]:
|
||
pair_key = f"{target_species}.{ref_species}"
|
||
pair = {"pair": pair_key, "target_species": target_species, "ref_species": ref_species, "success": False}
|
||
target_ready = species_position_results.get(target_species, {}).get("success")
|
||
ref_ready = species_position_results.get(ref_species, {}).get("success")
|
||
target_pep = discovery["target_all_peps"].get(target_species)
|
||
ref_pep = discovery["ref_all_peps"].get(ref_species)
|
||
if not (target_ready and ref_ready and target_pep and ref_pep):
|
||
pair["error"] = "missing matched GFF/BED or protein FASTA"
|
||
pair_results.append(pair)
|
||
print(f" {pair_key}: skipped ({pair['error']})")
|
||
continue
|
||
blast_result = run_reciprocal_blast(Path(target_pep), Path(ref_pep), paths["analysis_dir"], target_species, ref_species, args.cpu, args.blast_engine, args.blast_evalue, args.max_target_seqs)
|
||
pair["blast_success"] = bool(blast_result.get("success"))
|
||
pair["blast_engine_requested"] = blast_result.get("engine_requested", args.blast_engine)
|
||
pair["blast_engine_used"] = blast_result.get("engine_used")
|
||
pair["fallback_from"] = blast_result.get("fallback_from")
|
||
pair["blast_commands"] = blast_result.get("commands", [])
|
||
pair["blast_stderr"] = blast_result.get("stderr", "")
|
||
pair["diamond_stderr"] = blast_result.get("diamond_stderr", "")
|
||
pair["blast_output_stats"] = blast_result.get("output_stats", {})
|
||
if blast_result.get("engine_used"):
|
||
metrics.setdefault("blast_engine_used", []).append(blast_result.get("engine_used"))
|
||
if blast_result.get("fallback_from"):
|
||
metrics["blast_fallback_count"] = metrics.get("blast_fallback_count", 0) + 1
|
||
if blast_result.get("output_file"):
|
||
pair["blast_file"] = str(blast_result["output_file"])
|
||
add_artifact(artifacts, f"{pair_key}_blast", blast_result["output_file"], f"interspecies_synteny/analysis/{pair_key}.blast", "blast", "data")
|
||
if not blast_result.get("success"):
|
||
pair["error"] = "BLAST failed or produced no hits"
|
||
pair_results.append(pair)
|
||
print(f" {pair_key}: {pair['error']}")
|
||
continue
|
||
mcscanx_result = run_mcscanx_interspecies(paths["analysis_dir"], target_species, ref_species)
|
||
pair_gff = paths["analysis_dir"] / f"{pair_key}.gff"
|
||
if pair_gff.exists():
|
||
pair["pair_gff"] = str(pair_gff)
|
||
add_artifact(artifacts, f"{pair_key}_gff", pair_gff, f"interspecies_synteny/analysis/{pair_key}.gff", "pair_mcscanx_gff", "gff")
|
||
pair["mcscanx_success"] = bool(mcscanx_result.get("success"))
|
||
if mcscanx_result.get("collinearity_file"):
|
||
pair["collinearity_file"] = str(mcscanx_result["collinearity_file"])
|
||
add_artifact(artifacts, f"{pair_key}_collinearity", mcscanx_result["collinearity_file"], f"interspecies_synteny/analysis/{pair_key}.collinearity", "collinearity", "data")
|
||
if not mcscanx_result.get("success"):
|
||
pair["error"] = "MCScanX did not produce collinearity"
|
||
pair_results.append(pair)
|
||
print(f" {pair_key}: {pair['error']}")
|
||
continue
|
||
simple_result = convert_collinearity_to_simple(paths["analysis_dir"], Path(mcscanx_result["collinearity_file"]), target_species, ref_species)
|
||
if not simple_result.get("success") and blast_result.get("output_file"):
|
||
fallback_result = convert_blast_to_family_simple(paths["analysis_dir"], Path(blast_result["output_file"]), target_species, ref_species)
|
||
if fallback_result.get("success"):
|
||
fallback_result["note"] = "MCScanX produced no whole-genome blocks; using family-only BLAST links for JCVI visualization"
|
||
simple_result = fallback_result
|
||
pair.update({k: str(v) if isinstance(v, Path) else v for k, v in simple_result.items()})
|
||
pair["success"] = bool(simple_result.get("success"))
|
||
if simple_result.get("anchors_file"):
|
||
add_artifact(artifacts, f"{pair_key}_anchors", simple_result["anchors_file"], f"interspecies_synteny/analysis/{pair_key}.anchors", "anchors", "data")
|
||
if simple_result.get("simple_file"):
|
||
add_artifact(artifacts, f"{pair_key}_anchors_simple", simple_result["simple_file"], f"interspecies_synteny/analysis/{pair_key}.anchors.simple", "anchors_simple", "data")
|
||
pair_results.append(pair)
|
||
print(f" {pair_key}: {'success' if pair['success'] else 'anchors conversion failed'}")
|
||
|
||
family_ids = collect_family_ids(Path(discovery["target_family_protein"]), Path(discovery["ref_family_protein"]), workspace / "final" / f"{args.prefix}.final_members.id")
|
||
successful_pairs = [p for p in pair_results if p.get("success")]
|
||
pairwise_plot_result = {"success": False, "pairs": [], "pairwise_plot_count": 0}
|
||
if args.plot_mode in {"multi", "both"}:
|
||
synteny_plot_result = plot_multi_species_synteny(
|
||
paths["analysis_dir"], paths["final_dir"], args.prefix,
|
||
discovery["target_species"] + discovery["ref_species"], pair_results, family_ids,
|
||
)
|
||
if args.plot_mode in {"pairwise", "both"}:
|
||
pairwise_plot_result = plot_pairwise_synteny(
|
||
paths["analysis_dir"], paths["final_dir"], args.prefix, pair_results, family_ids,
|
||
)
|
||
metrics["multi_plot_generated"] = bool(synteny_plot_result.get("success"))
|
||
metrics["pairwise_plot_success"] = bool(pairwise_plot_result.get("success"))
|
||
metrics["pairwise_plot_count"] = int(pairwise_plot_result.get("pairwise_plot_count") or 0)
|
||
metrics["pairwise_plots"] = pairwise_plot_result.get("pairs", [])
|
||
requested_plot_success = True
|
||
if args.plot_mode in {"multi", "both"}:
|
||
requested_plot_success = requested_plot_success and bool(synteny_plot_result.get("success"))
|
||
if args.plot_mode in {"pairwise", "both"}:
|
||
requested_plot_success = requested_plot_success and bool(pairwise_plot_result.get("success"))
|
||
metrics["synteny_success"] = requested_plot_success and len(successful_pairs) == len(pair_results) and bool(pair_results)
|
||
if not successful_pairs:
|
||
metrics["synteny_skipped_reason"] = synteny_plot_result.get("error") or pairwise_plot_result.get("error") or "No target-ref pair produced JCVI anchors"
|
||
elif not requested_plot_success:
|
||
metrics["synteny_skipped_reason"] = synteny_plot_result.get("error") or pairwise_plot_result.get("error") or "Requested synteny plot output was not generated"
|
||
else:
|
||
metrics["synteny_skipped_reason"] = "Missing full data for MCScanX: " + ", ".join(missing)
|
||
print(metrics["synteny_skipped_reason"])
|
||
|
||
metrics["species_position_results"] = {
|
||
sp: {k: str(v) if isinstance(v, Path) else v for k, v in result.items()}
|
||
for sp, result in species_position_results.items()
|
||
}
|
||
metrics["pairwise_synteny"] = pair_results
|
||
metrics["pair_success_count"] = sum(1 for p in pair_results if p.get("success"))
|
||
metrics["pair_total_count"] = len(pair_results)
|
||
|
||
if synteny_plot_result.get("pdf_file"):
|
||
add_artifact(artifacts, "synteny_pdf", synteny_plot_result["pdf_file"], f"final/{args.prefix}.interspecies_synteny.pdf", "synteny_pdf", "plot")
|
||
if synteny_plot_result.get("png_file"):
|
||
add_artifact(artifacts, "synteny_png", synteny_plot_result["png_file"], f"final/{args.prefix}.interspecies_synteny.png", "synteny_png", "plot")
|
||
if synteny_plot_result.get("marked_pdf_file"):
|
||
add_artifact(artifacts, "synteny_marked_pdf", synteny_plot_result["marked_pdf_file"], f"final/{args.prefix}.interspecies_synteny.marked.pdf", "synteny_marked_pdf", "plot")
|
||
if synteny_plot_result.get("marked_png_file"):
|
||
add_artifact(artifacts, "synteny_marked_png", synteny_plot_result["marked_png_file"], f"final/{args.prefix}.interspecies_synteny.marked.png", "synteny_marked_png", "plot")
|
||
if synteny_plot_result.get("seqids_file"):
|
||
add_artifact(artifacts, "jcvi_seqids", synteny_plot_result["seqids_file"], f"interspecies_synteny/analysis/{args.prefix}.seqids", "jcvi_seqids", "data")
|
||
if synteny_plot_result.get("layout_file"):
|
||
add_artifact(artifacts, "jcvi_layout", synteny_plot_result["layout_file"], f"interspecies_synteny/analysis/{args.prefix}.layout", "jcvi_layout", "data")
|
||
for pair_plot in (metrics.get("pairwise_plots") or []):
|
||
pair_key = sanitize_token(pair_plot.get("pair") or "pair")
|
||
for artifact_key, file_key, artifact_name, file_type in [
|
||
("pairwise_pdf", "pdf_file", "pairwise_synteny_pdf", "plot"),
|
||
("pairwise_png", "png_file", "pairwise_synteny_png", "plot"),
|
||
("pairwise_marked_pdf", "marked_pdf_file", "pairwise_synteny_marked_pdf", "plot"),
|
||
("pairwise_marked_png", "marked_png_file", "pairwise_synteny_marked_png", "plot"),
|
||
("pairwise_seqids", "seqids_file", "pairwise_jcvi_seqids", "data"),
|
||
("pairwise_layout", "layout_file", "pairwise_jcvi_layout", "data"),
|
||
("pairwise_layout_mark", "layout_mark_file", "pairwise_jcvi_layout_mark", "data"),
|
||
]:
|
||
file_text = pair_plot.get(file_key) or ""
|
||
if not file_text:
|
||
continue
|
||
file_path = Path(file_text)
|
||
if not file_path.exists():
|
||
continue
|
||
rel = f"final/{file_path.name}" if file_path.parent == paths["final_dir"] else f"interspecies_synteny/analysis/{file_path.name}"
|
||
add_artifact(artifacts, f"{pair_key}_{artifact_key}", file_path, rel, artifact_name, file_type)
|
||
|
||
end_time = datetime.now()
|
||
status = "success" if metrics["tree_success"] and metrics["synteny_success"] else "partial_success"
|
||
parameters = {
|
||
"prefix": args.prefix,
|
||
"gene_family": discovery["gene_family"],
|
||
"target_group": target_group,
|
||
"ref_group": ref_group,
|
||
"target_species": discovery["target_species"],
|
||
"ref_species": discovery["ref_species"],
|
||
"available_target_species": discovery.get("available_target_species", discovery["target_species"]),
|
||
"available_ref_species": discovery.get("available_ref_species", discovery["ref_species"]),
|
||
"selected_target_species": discovery.get("selected_target_species", discovery["target_species"]),
|
||
"selected_ref_species": discovery.get("selected_ref_species", discovery["ref_species"]),
|
||
"target_protein_spl": str(discovery["target_family_protein"]),
|
||
"ref_protein_spl": str(discovery["ref_family_protein"]),
|
||
"input_dir": str(input_dir),
|
||
"cpu": args.cpu,
|
||
"blast_engine_requested": args.blast_engine,
|
||
"plot_mode": args.plot_mode,
|
||
"blast_evalue": args.blast_evalue,
|
||
"max_target_seqs": args.max_target_seqs,
|
||
"tree_subfamily_arc_enabled": str(args.tree_subfamily_arc_enabled).lower() != "false",
|
||
"tree_subfamily_arc_gap": args.tree_subfamily_arc_gap,
|
||
"tree_subfamily_arc_label_gap": args.tree_subfamily_arc_label_gap,
|
||
"tree_subfamily_arc_label_size": args.tree_subfamily_arc_label_size,
|
||
}
|
||
if scope_data:
|
||
parameters.update({
|
||
"scope_file": scope_file,
|
||
"scope_id": scope_data.get("scope_id", ""),
|
||
"scope_mode": scope_data.get("scope_mode", ""),
|
||
"target_species": scope_data.get("target_species", []),
|
||
"reference_species": scope_data.get("reference_species", []),
|
||
"comparative_species": scope_data.get("comparative_species", []),
|
||
"selected_species": scope_data.get("selected_species", []),
|
||
"family": scope_data.get("family", ""),
|
||
})
|
||
route = analysis_route or infer_analysis_route("interspecies_synteny", scope_data)
|
||
parameters["analysis_route"] = route
|
||
parameters["member_set_id"] = member_set_id or member_set_id_for(args.prefix, scope_data, route)
|
||
report_path = write_report(workspace, args.prefix, metrics, parameters, tool_versions, start_time, end_time, (end_time - start_time).total_seconds(), cmd)
|
||
add_artifact(artifacts, "report", report_path, f"final/{args.prefix}.interspecies_synteny_report.md", "report", "report")
|
||
manifest_path = write_manifest(workspace, job_id, run_id, args.prefix, start_time, end_time, artifacts, metrics, parameters, tool_versions, workspace_root, input_dir, cmd, status=status)
|
||
add_artifact(artifacts, "manifest", manifest_path, f"final/{args.prefix}.interspecies_synteny.manifest.json", "manifest", "json")
|
||
write_manifest(workspace, job_id, run_id, args.prefix, start_time, end_time, artifacts, metrics, parameters, tool_versions, workspace_root, input_dir, cmd, status=status)
|
||
print(f"Interspecies synteny finished with status: {status}")
|
||
print(f"Manifest: {manifest_path}")
|
||
return 0 if metrics["tree_success"] else 1
|
||
except Exception as exc:
|
||
print(f"Interspecies synteny failed: {exc}")
|
||
try:
|
||
write_failure_manifest(
|
||
workspace, job_id, run_id, args.prefix, start_time,
|
||
workspace_root, input_dir, cmd, str(exc),
|
||
scope_data=scope_data, scope_file=scope_file,
|
||
analysis_route=analysis_route, member_set_id=member_set_id,
|
||
)
|
||
except Exception as manifest_exc:
|
||
print(f"Failed to write failure manifest: {manifest_exc}")
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|