#!/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..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())