2382 lines
96 KiB
Python
Executable File
2382 lines
96 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Phylogeny阶段:对基因家族成员进行多序列比对和系统发育分析
|
||
|
||
输入:Identify阶段的final/{prefix}.final_members.pep.fa
|
||
输出:
|
||
- final/{prefix}.pep.msa.fa (多序列比对)
|
||
- final/{prefix}.tree.nwk (系统发育树)
|
||
- final/{prefix}.phylogeny_report.md (分析报告)
|
||
- final/{prefix}.phylogeny.manifest.json (机器清单)
|
||
"""
|
||
|
||
import os
|
||
import argparse
|
||
import json
|
||
import re
|
||
import shlex
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List, Any, Optional
|
||
import hashlib
|
||
import time
|
||
import shutil # 需要添加这个导入,用于复制文件
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||
if str(PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
||
from gfa_core.gene_family_scope import read_scope, sanitize_token
|
||
|
||
CUSTOM_PHYLOGENY_ROUTES = {"custom_phylogeny", "comparative_phylogeny"}
|
||
|
||
|
||
def route_output_suffix(analysis_route: str) -> str:
|
||
route = str(analysis_route or "").strip()
|
||
if route in CUSTOM_PHYLOGENY_ROUTES:
|
||
return sanitize_token(route)
|
||
return ""
|
||
|
||
|
||
from gfa_core.gene_family_stage_context import apply_stage_context, build_stage_context, user_output_prefix_for, write_legacy_manifest_copy
|
||
|
||
# Karpathy: 添加conda环境bin目录到PATH
|
||
CONDA_PREFIX = Path(sys.executable).resolve().parent.parent
|
||
CONDA_BIN = str(CONDA_PREFIX / "bin")
|
||
if CONDA_BIN not in os.environ.get("PATH", "").split(os.pathsep):
|
||
os.environ["PATH"] = CONDA_BIN + os.pathsep + os.environ.get("PATH", "")
|
||
|
||
|
||
def resolve_iqtree_executable() -> str:
|
||
"""Resolve the IQ-TREE executable used by this Python environment."""
|
||
candidates = [
|
||
Path(sys.executable).resolve().parent / "iqtree3",
|
||
Path(CONDA_BIN) / "iqtree3",
|
||
shutil.which("iqtree3"),
|
||
shutil.which("iqtree2"),
|
||
shutil.which("iqtree"),
|
||
]
|
||
for candidate in candidates:
|
||
if not candidate:
|
||
continue
|
||
path = Path(candidate)
|
||
if path.exists() and os.access(path, os.X_OK):
|
||
return str(path)
|
||
return "iqtree3"
|
||
|
||
|
||
def get_tool_versions() -> Dict[str, str]:
|
||
"""获取MAFFT和IQ-TREE版本信息"""
|
||
versions = {}
|
||
|
||
# MAFFT版本 - 修复版本获取
|
||
try:
|
||
# 尝试不同的方法来获取MAFFT版本
|
||
# MAFFT的--version可能输出到stderr
|
||
result = subprocess.run(
|
||
["mafft", "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=5
|
||
)
|
||
|
||
# 检查stdout和stderr
|
||
output = result.stdout.strip() or result.stderr.strip()
|
||
|
||
if output:
|
||
# 提取版本信息
|
||
# MAFFT输出格式可能是:
|
||
# 1. "MAFFT v7.505 (2023/Jul/25)"
|
||
# 2. "v7.505"
|
||
# 3. 或其他格式
|
||
lines = output.split('\n')
|
||
for line in lines:
|
||
line = line.strip()
|
||
if line:
|
||
# 查找包含版本信息的行
|
||
if "MAFFT v" in line:
|
||
versions["mafft"] = line
|
||
break
|
||
elif line.startswith("v") and "." in line:
|
||
versions["mafft"] = f"MAFFT {line}"
|
||
break
|
||
|
||
# 如果没找到特定格式,使用第一行
|
||
if "mafft" not in versions and lines:
|
||
versions["mafft"] = lines[0]
|
||
else:
|
||
versions["mafft"] = "unknown (no output)"
|
||
|
||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||
versions["mafft"] = "not_found"
|
||
except subprocess.TimeoutExpired:
|
||
versions["mafft"] = "timeout"
|
||
except Exception as e:
|
||
versions["mafft"] = f"error: {str(e)[:50]}"
|
||
|
||
# IQ-TREE版本(与实际执行命令保持一致)
|
||
try:
|
||
iqtree_exe = resolve_iqtree_executable()
|
||
result = subprocess.run(
|
||
[iqtree_exe, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False
|
||
)
|
||
if result.returncode == 0:
|
||
# 提取版本信息
|
||
lines = result.stdout.strip().split('\n')
|
||
for line in lines:
|
||
if "IQ-TREE version" in line or "IQ-TREE" in line:
|
||
versions["iqtree"] = line.strip()
|
||
break
|
||
if "iqtree" not in versions:
|
||
versions["iqtree"] = lines[0] if lines else "unknown"
|
||
else:
|
||
versions["iqtree"] = f"error_{result.returncode}"
|
||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||
versions["iqtree"] = "not_found"
|
||
|
||
return versions
|
||
|
||
|
||
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 infer_effective_prefix(workspace: Path, requested_prefix: str = "") -> str:
|
||
"""Return a stable non-hidden prefix for phylogeny outputs."""
|
||
if requested_prefix:
|
||
return requested_prefix
|
||
|
||
final_dir = workspace / "final"
|
||
|
||
for pep in sorted(final_dir.glob("Multi.*.final_members.pep.fa")):
|
||
suffix = ".final_members.pep.fa"
|
||
if pep.name.endswith(suffix):
|
||
return pep.name[:-len(suffix)]
|
||
|
||
for manifest_path in sorted(final_dir.glob("*.identify.manifest.json")):
|
||
if manifest_path.name.startswith("."):
|
||
continue
|
||
try:
|
||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
prefix = data.get("prefix")
|
||
if prefix:
|
||
return prefix
|
||
gene_family = data.get("gene_family")
|
||
if gene_family:
|
||
return f"Multi.{gene_family}"
|
||
except Exception:
|
||
continue
|
||
|
||
legacy_manifest = final_dir / ".identify.manifest.json"
|
||
if legacy_manifest.exists():
|
||
try:
|
||
data = json.loads(legacy_manifest.read_text(encoding="utf-8"))
|
||
prefix = data.get("prefix")
|
||
if prefix:
|
||
return prefix
|
||
gene_family = data.get("gene_family")
|
||
if gene_family:
|
||
return f"Multi.{gene_family}"
|
||
except Exception:
|
||
pass
|
||
|
||
raise FileNotFoundError("Unable to infer prefix: no Multi.*.final_members.pep.fa or identify manifest found")
|
||
|
||
def validate_identify_output(workspace: Path, prefix: str) -> Dict[str, Any]:
|
||
"""
|
||
验证Identify阶段的输出文件并获取必要信息
|
||
|
||
Args:
|
||
workspace: 工作空间目录(workspace/<job_id>)
|
||
prefix: 分析前缀,如 Ft.SPL
|
||
|
||
Returns:
|
||
包含输入文件路径和Identify manifest数据的字典
|
||
|
||
Raises:
|
||
FileNotFoundError: Identify输出不存在
|
||
ValueError: 文件无效或格式错误
|
||
"""
|
||
print(f"正在验证Identify阶段输出: {workspace}")
|
||
|
||
# 1. 查找Identify的manifest
|
||
identify_manifest_path = workspace / "final" / f"{prefix}.identify.manifest.json"
|
||
single_species_protein: Optional[Path] = None
|
||
if not identify_manifest_path.exists() and "." in prefix:
|
||
species, family = prefix.split(".", 1)
|
||
if species not in {"Target", "Multi"} and family:
|
||
candidate_manifest = workspace / "final" / f"Target.{family}.identify.manifest.json"
|
||
species_candidates = [
|
||
workspace / "final" / f"Target.{species}.{family}.identified_members.pep.fa",
|
||
workspace / "final" / f"Target.{species}.{family}.final_members.pep.fa",
|
||
workspace / "identify" / species / "final" / f"{species}.final_gfmembers.pep.fa",
|
||
]
|
||
single_species_protein = next((p for p in species_candidates if p.exists() and p.stat().st_size > 0), None)
|
||
if candidate_manifest.exists() and single_species_protein:
|
||
identify_manifest_path = candidate_manifest
|
||
if not identify_manifest_path.exists():
|
||
raise FileNotFoundError(f"Identify manifest未找到: {identify_manifest_path}")
|
||
|
||
print(f" 找到Identify manifest: {identify_manifest_path}")
|
||
|
||
# 2. 读取Identify manifest
|
||
with open(identify_manifest_path, 'r') as f:
|
||
identify_manifest = json.load(f)
|
||
|
||
# 3. 验证Identify状态
|
||
if identify_manifest.get("status") != "success":
|
||
raise ValueError(f"Identify阶段未成功完成,状态: {identify_manifest.get('status')}")
|
||
|
||
# 4. 查找蛋白序列文件
|
||
# artifacts可能是列表(旧格式)或字典(新格式)
|
||
artifacts = identify_manifest.get("artifacts", {})
|
||
protein_artifact = None
|
||
|
||
if isinstance(artifacts, list):
|
||
# 列表格式:查找artifact为"final_proteins"的项
|
||
for artifact in artifacts:
|
||
if isinstance(artifact, dict) and artifact.get("artifact") == "final_proteins":
|
||
protein_artifact = artifact
|
||
break
|
||
elif isinstance(artifacts, dict):
|
||
# 字典格式:直接查找"final_proteins"键
|
||
protein_artifact = artifacts.get("final_proteins")
|
||
|
||
if not protein_artifact:
|
||
# 尝试其他可能的键名
|
||
for key in ["identified_members", "final_proteins", "final_members", "proteins"]:
|
||
if key in artifacts:
|
||
protein_artifact = artifacts[key] if isinstance(artifacts, dict) else None
|
||
break
|
||
|
||
if single_species_protein:
|
||
protein_path = single_species_protein
|
||
protein_artifact = {
|
||
"full_path": str(single_species_protein),
|
||
"artifact": "species_final_members",
|
||
}
|
||
elif not protein_artifact:
|
||
raise ValueError("在Identify manifest中找不到final_proteins artifact")
|
||
|
||
# 获取文件路径
|
||
if not single_species_protein:
|
||
protein_path = manifest_artifact_path(workspace, identify_manifest_path, protein_artifact)
|
||
if not protein_path:
|
||
raw_full_path = Path(protein_artifact.get("full_path", ""))
|
||
protein_path = raw_full_path if raw_full_path.exists() and inside_current_job(workspace, raw_full_path) else Path("")
|
||
if not protein_path.exists():
|
||
# 尝试相对路径
|
||
rel_path = protein_artifact.get("path", "")
|
||
if rel_path:
|
||
protein_path = resolve_job_path(workspace, rel_path, identify_manifest_path) or (workspace / "final" / Path(rel_path).name)
|
||
|
||
if not protein_path.exists():
|
||
raise FileNotFoundError(f"蛋白序列文件未找到: {protein_path}")
|
||
|
||
# 检查文件是否为空
|
||
if protein_path.stat().st_size == 0:
|
||
raise ValueError(f"蛋白序列文件为空: {protein_path}")
|
||
|
||
print(f" 找到蛋白序列文件: {protein_path}")
|
||
print(f" 文件大小: {protein_path.stat().st_size} 字节")
|
||
|
||
return {
|
||
"protein_fasta": protein_path,
|
||
"identify_manifest": identify_manifest,
|
||
"identify_metrics": identify_manifest.get("metrics", {}),
|
||
"identify_parameters": identify_manifest.get("parameters", {})
|
||
}
|
||
|
||
|
||
def read_fasta_records(fasta_file: Path) -> List[Dict[str, str]]:
|
||
records: List[Dict[str, str]] = []
|
||
current_header = ""
|
||
current_seq: List[str] = []
|
||
with fasta_file.open("r", encoding="utf-8", errors="ignore") as handle:
|
||
for raw in handle:
|
||
line = raw.strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith(">"):
|
||
if current_header:
|
||
records.append({
|
||
"id": current_header.split()[0],
|
||
"header": current_header,
|
||
"seq": "".join(current_seq),
|
||
})
|
||
current_header = line[1:].strip()
|
||
current_seq = []
|
||
else:
|
||
current_seq.append(line)
|
||
if current_header:
|
||
records.append({
|
||
"id": current_header.split()[0],
|
||
"header": current_header,
|
||
"seq": "".join(current_seq),
|
||
})
|
||
return records
|
||
|
||
|
||
def write_fasta_records(records: List[Dict[str, str]], output_file: Path) -> None:
|
||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||
with output_file.open("w", encoding="utf-8") as handle:
|
||
for record in records:
|
||
handle.write(f">{record['header']}\n")
|
||
seq = record["seq"].replace("*", "")
|
||
for idx in range(0, len(seq), 80):
|
||
handle.write(seq[idx:idx + 80] + "\n")
|
||
|
||
|
||
def display_path(path: Path | str) -> str:
|
||
"""Return a stable user-facing absolute path without expanding symlinks."""
|
||
return str(Path(path).absolute())
|
||
|
||
|
||
def current_input_dir(workspace: Path) -> Path:
|
||
return workspace.parent.parent / "input" / workspace.name
|
||
|
||
|
||
def inside_current_job(workspace: Path, path: Path) -> bool:
|
||
input_dir = current_input_dir(workspace)
|
||
try:
|
||
resolved = path.resolve()
|
||
return resolved.is_relative_to(workspace.resolve()) or resolved.is_relative_to(input_dir.resolve())
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def rebase_current_job_path(workspace: Path, path: Path) -> Optional[Path]:
|
||
"""Map copied legacy workspace/input paths back onto the current job roots."""
|
||
parts = path.parts
|
||
for root_name, current_root in (("workspace", workspace), ("input", current_input_dir(workspace))):
|
||
for idx, part in enumerate(parts):
|
||
if part != root_name:
|
||
continue
|
||
if idx + 1 >= len(parts) or parts[idx + 1] != workspace.name:
|
||
continue
|
||
suffix = Path(*parts[idx + 2:]) if idx + 2 < len(parts) else Path()
|
||
candidate = current_root / suffix
|
||
if candidate.exists():
|
||
return candidate
|
||
input_dir = current_input_dir(workspace)
|
||
by_name_candidates = [
|
||
input_dir / path.name,
|
||
workspace / "final" / path.name,
|
||
workspace / "phylogeny" / "analysis" / path.name,
|
||
]
|
||
for candidate in by_name_candidates:
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def resolve_job_path(workspace: Path, raw_path: str | Path, manifest_path: Optional[Path] = None) -> Optional[Path]:
|
||
"""Resolve a copied job path without trusting stale absolute full_path values."""
|
||
raw_text = str(raw_path or "").strip()
|
||
if not raw_text:
|
||
return None
|
||
path = Path(raw_text)
|
||
if path.is_absolute():
|
||
if inside_current_job(workspace, path):
|
||
return path
|
||
rebased = rebase_current_job_path(workspace, path)
|
||
if rebased:
|
||
return rebased
|
||
return None
|
||
|
||
candidates: List[Path] = [workspace / path, current_input_dir(workspace) / path]
|
||
if manifest_path:
|
||
candidates.extend([manifest_path.parent / path, manifest_path.parent / path.name])
|
||
if str(path).startswith("final/") and manifest_path:
|
||
candidates.append(manifest_path.parent / path.name)
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def manifest_artifact_path(workspace: Path, manifest_path: Path, artifact: Dict[str, Any]) -> Optional[Path]:
|
||
if not isinstance(artifact, dict):
|
||
return None
|
||
for key in ("path", "relpath_or_hint", "file", "full_path"):
|
||
resolved = resolve_job_path(workspace, artifact.get(key) or "", manifest_path)
|
||
if resolved:
|
||
return resolved
|
||
return None
|
||
|
||
|
||
def rebase_job_path_text(workspace: Path, value: str) -> str:
|
||
"""Rebase old job paths embedded in manifest strings or command history."""
|
||
if not value:
|
||
return value
|
||
if value.startswith("/"):
|
||
direct = rebase_current_job_path(workspace, Path(value))
|
||
if direct:
|
||
return display_path(direct)
|
||
if "workspace/" + workspace.name not in value and "input/" + workspace.name not in value:
|
||
return value
|
||
|
||
def replace_match(match: re.Match[str]) -> str:
|
||
raw_path = match.group(0)
|
||
trailing = ""
|
||
while raw_path and raw_path[-1] in {",", ";", ")", "]", "}"}:
|
||
trailing = raw_path[-1] + trailing
|
||
raw_path = raw_path[:-1]
|
||
rebased = resolve_job_path(workspace, raw_path)
|
||
return (display_path(rebased) if rebased else raw_path) + trailing
|
||
|
||
return re.sub(r"/[^\s\"']*(?:workspace|input)/" + re.escape(workspace.name) + r"/[^\s\"']*", 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 first_existing_scope_file(workspace: Path, scope_data: Dict[str, Any], species: str, file_types: List[str]) -> Optional[Path]:
|
||
species_files = scope_data.get("files", {}).get(species, {})
|
||
for file_type in file_types:
|
||
for record in species_files.get(file_type, []) or []:
|
||
path_text = record.get("path") or ""
|
||
if path_text:
|
||
path = resolve_job_path(workspace, path_text) or Path(path_text)
|
||
if path.exists() and path.stat().st_size > 0:
|
||
return path
|
||
return None
|
||
|
||
|
||
def infer_input_dir(workspace: Path, scope_file: Optional[Path]) -> Path:
|
||
"""Resolve the job input directory without requiring the caller to pass it."""
|
||
candidates: List[Path] = []
|
||
if scope_file:
|
||
# Common generated scope path:
|
||
# workspace/<job_id>/final/<prefix>.<scope_id>.scope.json
|
||
try:
|
||
scope_parent = scope_file.parent
|
||
if scope_parent.name == "final":
|
||
job_workspace = scope_parent.parent
|
||
candidates.append(job_workspace.parent.parent / "input" / job_workspace.name)
|
||
except Exception:
|
||
pass
|
||
candidates.extend([
|
||
workspace.parent.parent / "input" / workspace.name,
|
||
workspace / "input",
|
||
])
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return candidate
|
||
return candidates[0]
|
||
|
||
|
||
def find_species_family_pep(
|
||
workspace: Path,
|
||
input_dir: Path,
|
||
species: str,
|
||
family: str,
|
||
scope_data: Dict[str, Any],
|
||
) -> Optional[Path]:
|
||
"""Find a known family protein FASTA for reference/comparative species."""
|
||
family_tokens = [family] if family else []
|
||
prefix_tokens = [f"{species}.{token}" for token in family_tokens]
|
||
candidates: List[Path] = []
|
||
for prefix_token in prefix_tokens:
|
||
candidates.extend([
|
||
workspace / "final" / f"{prefix_token}.pep.fa",
|
||
workspace / "final" / f"{prefix_token}.pep.fasta",
|
||
workspace / "final" / f"{prefix_token}.faa",
|
||
workspace / "final" / f"{prefix_token}.sequence_extract.pep.fa",
|
||
workspace / "final" / f"{prefix_token}.sequence_extract.pep.fasta",
|
||
workspace / "final" / f"{prefix_token}.ncbi_fetch.protein.fa",
|
||
workspace / "final" / f"{prefix_token}.ncbi_fetch.protein.fasta",
|
||
input_dir / f"{prefix_token}.pep.fa",
|
||
input_dir / f"{prefix_token}.pep.fasta",
|
||
input_dir / f"{prefix_token}.faa",
|
||
input_dir / f"{prefix_token}.sequence_extract.pep.fa",
|
||
input_dir / f"{prefix_token}.sequence_extract.pep.fasta",
|
||
input_dir / f"{prefix_token}.ncbi_fetch.protein.fa",
|
||
input_dir / f"{prefix_token}.ncbi_fetch.protein.fasta",
|
||
input_dir / species / f"{prefix_token}.pep.fa",
|
||
input_dir / species / f"{prefix_token}.pep.fasta",
|
||
input_dir / species / f"{prefix_token}.faa",
|
||
input_dir / species / f"{prefix_token}.sequence_extract.pep.fa",
|
||
input_dir / species / f"{prefix_token}.sequence_extract.pep.fasta",
|
||
input_dir / species / f"{prefix_token}.ncbi_fetch.protein.fa",
|
||
input_dir / species / f"{prefix_token}.ncbi_fetch.protein.fasta",
|
||
])
|
||
|
||
for candidate in candidates:
|
||
if candidate.exists() and candidate.stat().st_size > 0:
|
||
return candidate
|
||
scoped = first_existing_scope_file(workspace, scope_data, species, ["family_pep"])
|
||
if scoped:
|
||
return scoped
|
||
return None
|
||
|
||
|
||
def find_refined_species_members(
|
||
workspace: Path,
|
||
input_dir: Path,
|
||
species: str,
|
||
family: str,
|
||
scope_data: Dict[str, Any],
|
||
) -> Optional[Path]:
|
||
"""Resolve the target species member FASTA for scoped phylogeny.
|
||
|
||
The subfamily evidence route is pre-final and must prefer curated_members.
|
||
Regular gene-family routes remain anchored on final_members.
|
||
"""
|
||
family = str(family or "").strip()
|
||
candidates: List[Path] = []
|
||
scope_prefix = str(scope_data.get("prefix") or "").strip()
|
||
analysis_route = str(scope_data.get("analysisRoute") or scope_data.get("analysis_route") or "").strip()
|
||
stems = ["curated_members"] if analysis_route == "subfamily_evidence" else ["final_members"]
|
||
|
||
for stem in stems:
|
||
if family:
|
||
candidates.extend([
|
||
workspace / "final" / f"Target.{species}.{family}.{stem}.pep.fa",
|
||
input_dir / "final" / f"Target.{species}.{family}.{stem}.pep.fa",
|
||
])
|
||
# In custom multi-species scopes the scope prefix can be an aggregate or a
|
||
# stale user-facing file. Prefer the explicit per-species Target artifact
|
||
# above; keep the scope-prefix fallback only after that.
|
||
if scope_prefix and scope_prefix.split(".", 1)[0] == species:
|
||
candidates.append(workspace / "final" / f"{scope_prefix}.{stem}.pep.fa")
|
||
candidates.append(input_dir / "final" / f"{scope_prefix}.{stem}.pep.fa")
|
||
if family:
|
||
candidates.extend([
|
||
workspace / "final" / f"{species}.{family}.{stem}.pep.fa",
|
||
input_dir / "final" / f"{species}.{family}.{stem}.pep.fa",
|
||
])
|
||
candidates.extend([
|
||
workspace / "final" / f"{species}.{stem}.pep.fa",
|
||
input_dir / "final" / f"{species}.{stem}.pep.fa",
|
||
])
|
||
|
||
seen: set[Path] = set()
|
||
for candidate in candidates:
|
||
try:
|
||
resolved = candidate.absolute()
|
||
except Exception:
|
||
resolved = candidate
|
||
if resolved in seen:
|
||
continue
|
||
seen.add(resolved)
|
||
if candidate.exists() and candidate.stat().st_size > 0:
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def prepare_phylogeny_scope_fasta(
|
||
workspace: Path,
|
||
prefix: str,
|
||
identify_info: Dict[str, Any],
|
||
scope_file: Optional[Path],
|
||
fallback_fasta: Path,
|
||
analysis_route: str = "",
|
||
) -> tuple[Path, Dict[str, Any]]:
|
||
if not scope_file:
|
||
records = read_fasta_records(fallback_fasta)
|
||
return fallback_fasta, {
|
||
"enabled": False,
|
||
"input_fasta": display_path(fallback_fasta),
|
||
"sequence_count": len(records),
|
||
}
|
||
|
||
scope_data = read_scope(scope_file)
|
||
if analysis_route and not (scope_data.get("analysisRoute") or scope_data.get("analysis_route")):
|
||
scope_data["analysisRoute"] = analysis_route
|
||
scope_data["analysis_route"] = analysis_route
|
||
scope_id = scope_data.get("scope_id", "")
|
||
selected_species = scope_data.get("selected_species") or scope_data.get("target_species") or []
|
||
if not selected_species:
|
||
selected_species = identify_info.get("identify_manifest", {}).get("target_species", [])
|
||
|
||
identify_manifest = identify_info.get("identify_manifest", {})
|
||
artifacts = identify_manifest.get("artifacts", {}) or {}
|
||
identify_species = identify_manifest.get("target_species", []) or []
|
||
reference_species = scope_data.get("reference_species") or []
|
||
comparative_species = scope_data.get("comparative_species") or []
|
||
family = str(scope_data.get("family") or prefix.split(".")[-1] or "").strip()
|
||
input_dir = infer_input_dir(workspace, scope_file)
|
||
route = str(analysis_route or scope_data.get("analysisRoute") or scope_data.get("analysis_route") or "").strip()
|
||
target_species_set = {str(item).strip() for item in scope_data.get("target_species") or [] if str(item).strip()}
|
||
|
||
records: List[Dict[str, str]] = []
|
||
seen_ids: set[str] = set()
|
||
species_counts: Dict[str, int] = {}
|
||
sources: Dict[str, str] = {}
|
||
missing_species: List[str] = []
|
||
missing_details: Dict[str, List[str]] = {}
|
||
|
||
def add_records(species: str, fasta: Path, source: str) -> None:
|
||
added = 0
|
||
for record in read_fasta_records(fasta):
|
||
record_id = record["id"]
|
||
if record_id in seen_ids:
|
||
continue
|
||
seen_ids.add(record_id)
|
||
records.append(record)
|
||
added += 1
|
||
species_counts[species] = species_counts.get(species, 0) + added
|
||
sources[species] = display_path(fasta)
|
||
print(f" Scope FASTA add {species}: {added} sequences from {source}: {fasta}")
|
||
|
||
for species in selected_species:
|
||
refined_fasta = find_refined_species_members(
|
||
workspace=workspace,
|
||
input_dir=input_dir,
|
||
species=species,
|
||
family=family,
|
||
scope_data=scope_data,
|
||
)
|
||
if refined_fasta:
|
||
source_label = "curated_members" if route == "subfamily_evidence" else "final_members"
|
||
add_records(species, refined_fasta, source_label)
|
||
continue
|
||
|
||
if route == "subfamily_evidence" and species in target_species_set:
|
||
missing_species.append(species)
|
||
missing_details[species] = [
|
||
str(workspace / "final" / f"Target.{species}.{family}.curated_members.pep.fa"),
|
||
str(input_dir / "final" / f"Target.{species}.{family}.curated_members.pep.fa"),
|
||
str(workspace / "final" / f"{species}.{family}.curated_members.pep.fa"),
|
||
"请先完成 identify 成员初筛确认,生成 curated_members 后再运行亚家族证据进化树。",
|
||
]
|
||
continue
|
||
|
||
artifact = artifacts.get(f"{species}_final_members") if isinstance(artifacts, dict) else None
|
||
artifact_path = manifest_artifact_path(workspace, workspace / "final" / f"{prefix}.identify.manifest.json", artifact) if artifact else None
|
||
if artifact_path and artifact_path.exists() and artifact_path.stat().st_size > 0:
|
||
add_records(species, artifact_path, "identify")
|
||
continue
|
||
|
||
if species in identify_species:
|
||
fallback = workspace / "identify" / species / "final" / f"{species}.final_gfmembers.pep.fa"
|
||
if fallback.exists() and fallback.stat().st_size > 0:
|
||
add_records(species, fallback, "identify_fallback")
|
||
continue
|
||
|
||
if species in reference_species or species in comparative_species:
|
||
family_file = find_species_family_pep(
|
||
workspace=workspace,
|
||
input_dir=input_dir,
|
||
species=species,
|
||
family=family,
|
||
scope_data=scope_data,
|
||
)
|
||
if family_file:
|
||
add_records(species, family_file, "family_pep")
|
||
continue
|
||
|
||
missing_species.append(species)
|
||
missing_details[species] = [
|
||
f"identify artifact: {species}_final_members",
|
||
str(workspace / "identify" / species / "final" / f"{species}.final_gfmembers.pep.fa"),
|
||
str(input_dir / f"{species}.{family}.pep.fa"),
|
||
str(input_dir / f"{species}.{family}.pep.fasta"),
|
||
str(input_dir / species / f"{species}.{family}.pep.fasta"),
|
||
str(workspace / "final" / f"{species}.{family}.pep.fa"),
|
||
str(workspace / "final" / f"{species}.{family}.sequence_extract.pep.fa"),
|
||
str(workspace / "final" / f"{species}.{family}.ncbi_fetch.protein.fa"),
|
||
]
|
||
|
||
if missing_species:
|
||
raise RuntimeError(
|
||
"Phylogeny scope 缺少以下物种的家族蛋白序列: "
|
||
+ ",".join(missing_species)
|
||
+ "。请先运行对应物种 identify,或提供 Species.Family.pep.fa。"
|
||
+ " 已检查路径: "
|
||
+ json.dumps(missing_details, ensure_ascii=False)
|
||
)
|
||
if not records:
|
||
raise RuntimeError("Phylogeny scope 没有可用于建树的蛋白序列。")
|
||
|
||
output_fasta = workspace / "phylogeny" / "analysis" / f"{prefix}.{sanitize_token(scope_id)}.input.pep.fa"
|
||
write_fasta_records(records, output_fasta)
|
||
return output_fasta, {
|
||
"enabled": True,
|
||
"scope_file": display_path(scope_file),
|
||
"scope_id": scope_id,
|
||
"scope_mode": scope_data.get("scope_mode", ""),
|
||
"family": scope_data.get("family", ""),
|
||
"target_species": scope_data.get("target_species") or [],
|
||
"reference_species": reference_species,
|
||
"comparative_species": comparative_species,
|
||
"selected_species": selected_species,
|
||
"input_fasta": display_path(output_fasta),
|
||
"sequence_count": len(records),
|
||
"species_counts": species_counts,
|
||
"sources": sources,
|
||
"input_dir": display_path(input_dir) if input_dir.exists() else str(input_dir),
|
||
}
|
||
|
||
|
||
def prepare_directories(workspace: Path) -> Dict[str, Path]:
|
||
"""创建Phylogeny阶段的标准目录结构"""
|
||
paths = {
|
||
"analysis_dir": workspace / "phylogeny" / "analysis",
|
||
"final_dir": workspace / "final",
|
||
"logs_dir": workspace / "logs",
|
||
}
|
||
|
||
print("创建目录结构:")
|
||
for name, path in paths.items():
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
print(f" {name}: {path}")
|
||
|
||
return paths
|
||
|
||
|
||
def force_option_value(args: list[str], option: str, value: str) -> list[str]:
|
||
normalized: list[str] = []
|
||
skip_next = False
|
||
prefix = f"{option}="
|
||
for arg in args:
|
||
if skip_next:
|
||
skip_next = False
|
||
continue
|
||
if arg == option:
|
||
skip_next = True
|
||
continue
|
||
if arg.startswith(prefix):
|
||
continue
|
||
normalized.append(arg)
|
||
normalized.extend([option, value])
|
||
return normalized
|
||
|
||
|
||
def run_mafft_alignment(
|
||
input_fasta: Path,
|
||
output_msa: Path,
|
||
cpu: int = 8,
|
||
mafft_params: Optional[str] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
运行MAFFT进行多序列比对
|
||
|
||
Args:
|
||
input_fasta: 输入FASTA文件
|
||
output_msa: 输出MSA文件
|
||
cpu: CPU核心数
|
||
mafft_params: 自定义MAFFT参数
|
||
|
||
Returns:
|
||
包含命令、返回码、输出和指标的字典
|
||
"""
|
||
print(f"运行MAFFT比对: {input_fasta} -> {output_msa}")
|
||
|
||
if mafft_params:
|
||
params = force_option_value(shlex.split(mafft_params), "--thread", str(cpu))
|
||
cmd = ["mafft"] + params
|
||
print(f" 使用自定义参数: {' '.join(params)}")
|
||
else:
|
||
cmd = ["mafft", "--auto", "--thread", str(cpu)]
|
||
print(f" 使用默认参数: --auto --thread {cpu}")
|
||
|
||
cmd.append(str(input_fasta))
|
||
|
||
# 记录开始时间
|
||
start_time = time.time()
|
||
|
||
# 执行比对
|
||
try:
|
||
with open(output_msa, "w") as f_out:
|
||
result = subprocess.run(
|
||
cmd,
|
||
stdout=f_out,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
check=False
|
||
)
|
||
|
||
elapsed = time.time() - start_time
|
||
|
||
if result.returncode == 0:
|
||
print(f" MAFFT成功完成,耗时: {elapsed:.1f}秒")
|
||
|
||
# 计算MSA统计
|
||
metrics = compute_msa_metrics(output_msa)
|
||
metrics["alignment_time_sec"] = elapsed
|
||
|
||
return {
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stderr": result.stderr,
|
||
"metrics": metrics,
|
||
"output_file": output_msa,
|
||
"success": True
|
||
}
|
||
else:
|
||
print(f" MAFFT失败,返回码: {result.returncode}")
|
||
if result.stderr:
|
||
print(f" 错误信息: {result.stderr[:200]}")
|
||
|
||
return {
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stderr": result.stderr,
|
||
"metrics": {},
|
||
"output_file": None,
|
||
"success": False
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f" MAFFT执行异常: {e}")
|
||
return {
|
||
"command": " ".join(cmd),
|
||
"returncode": -1,
|
||
"stderr": str(e),
|
||
"metrics": {},
|
||
"output_file": None,
|
||
"success": False
|
||
}
|
||
|
||
|
||
def compute_msa_metrics(msa_file: Path) -> Dict[str, Any]:
|
||
"""计算MSA的统计指标"""
|
||
try:
|
||
sequences = []
|
||
current_seq = ""
|
||
current_id = ""
|
||
|
||
with open(msa_file, 'r') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith('>'):
|
||
if current_id:
|
||
sequences.append(current_seq)
|
||
current_id = line[1:].split()[0] if line[1:] else ""
|
||
current_seq = ""
|
||
elif line:
|
||
current_seq += line
|
||
|
||
if current_id:
|
||
sequences.append(current_seq)
|
||
|
||
if not sequences:
|
||
return {
|
||
"sequences_count": 0,
|
||
"msa_length": 0,
|
||
"gap_percentage": 0.0,
|
||
"min_length": 0,
|
||
"max_length": 0
|
||
}
|
||
|
||
# 检查序列长度一致性
|
||
lengths = [len(seq) for seq in sequences]
|
||
if len(set(lengths)) > 1:
|
||
print(f"警告: MSA序列长度不一致: {lengths}")
|
||
|
||
# 计算gap百分比
|
||
seq_count = len(sequences)
|
||
msa_length = max(lengths) if lengths else 0
|
||
total_chars = seq_count * msa_length
|
||
|
||
if total_chars > 0:
|
||
gap_chars = sum(seq.count('-') + seq.count('.') for seq in sequences)
|
||
gap_percentage = (gap_chars / total_chars * 100)
|
||
else:
|
||
gap_percentage = 0.0
|
||
|
||
return {
|
||
"sequences_count": seq_count,
|
||
"msa_length": msa_length,
|
||
"gap_percentage": round(gap_percentage, 2),
|
||
"min_length": min(lengths) if lengths else 0,
|
||
"max_length": max(lengths) if lengths else 0
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"计算MSA指标时出错: {e}")
|
||
return {
|
||
"sequences_count": 0,
|
||
"msa_length": 0,
|
||
"gap_percentage": 0.0,
|
||
"min_length": 0,
|
||
"max_length": 0
|
||
}
|
||
|
||
|
||
def count_tree_tips(tree_file: Path) -> int:
|
||
try:
|
||
from Bio import Phylo
|
||
|
||
tree = Phylo.read(str(tree_file), "newick")
|
||
return len(tree.get_terminals())
|
||
except Exception as exc:
|
||
print(f"计算tree tip数量失败: {exc}")
|
||
return 0
|
||
|
||
def run_iqtree_tree(
|
||
msa_file: Path,
|
||
output_dir: Path,
|
||
prefix: str,
|
||
cpu: int = 8,
|
||
iqtree_params: Optional[str] = None
|
||
) -> Dict[str, Any]:
|
||
"""Run IQ-TREE and return the generated tree file."""
|
||
print(f"Run IQ-TREE: {msa_file}")
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
base_name = output_dir / f"{prefix}.iqtree"
|
||
|
||
# IQ-TREE reuses checkpoints for the same -pre prefix. In this pipeline the
|
||
# same scope prefix can be rerun after upstream member IDs or sequences are
|
||
# corrected, so stale treefiles must not be accepted as current output.
|
||
for stale in output_dir.glob(f"{prefix}.iqtree*"):
|
||
if stale.is_file():
|
||
try:
|
||
stale.unlink()
|
||
except OSError as exc:
|
||
print(f" Warning: failed to remove stale IQ-TREE output {stale.name}: {exc}")
|
||
|
||
if iqtree_params:
|
||
params = force_option_value(shlex.split(iqtree_params), "-nt", str(cpu))
|
||
cmd = [resolve_iqtree_executable(), "-s", str(msa_file), "-pre", str(base_name)] + params
|
||
print(f" Custom IQ-TREE params: {' '.join(params)}")
|
||
else:
|
||
cmd = [resolve_iqtree_executable(), "-s", str(msa_file), "-pre", str(base_name), "-m", "MFP", "-bb", "1000", "-nt", str(cpu)]
|
||
print(f" Default IQ-TREE params: -m MFP -bb 1000 -nt {cpu}")
|
||
|
||
start_time = time.time()
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||
elapsed = time.time() - start_time
|
||
stdout = result.stdout or ""
|
||
stderr = result.stderr or ""
|
||
|
||
tree_candidates = [
|
||
base_name.with_suffix(".treefile"),
|
||
output_dir / f"{prefix}.iqtree.treefile",
|
||
base_name.with_suffix(".contree"),
|
||
output_dir / f"{prefix}.iqtree.contree",
|
||
]
|
||
tree_file = next((p for p in tree_candidates if p.exists() and p.stat().st_size > 0), None)
|
||
best_model = extract_best_model(stdout + "\n" + stderr)
|
||
|
||
checkpoint_finished = "previous run successfully finished" in (stdout + stderr)
|
||
success = tree_file is not None and (result.returncode == 0 or checkpoint_finished)
|
||
if success:
|
||
print(f" IQ-TREE completed, elapsed: {elapsed:.1f}s")
|
||
else:
|
||
print(f" IQ-TREE completed, elapsed: {elapsed:.1f}s")
|
||
if stderr:
|
||
print(f" Error: {stderr[:200]}")
|
||
|
||
return {
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": stdout,
|
||
"stderr": stderr,
|
||
"tree_file": tree_file,
|
||
"best_model": best_model,
|
||
"tree_building_time_sec": elapsed,
|
||
"success": success
|
||
}
|
||
except Exception as e:
|
||
print(f" IQ-TREE exception: {e}")
|
||
return {
|
||
"command": " ".join(cmd),
|
||
"returncode": -1,
|
||
"stdout": "",
|
||
"stderr": str(e),
|
||
"tree_file": None,
|
||
"best_model": "",
|
||
"tree_building_time_sec": 0,
|
||
"success": False
|
||
}
|
||
|
||
def run_msa_plotting(
|
||
msa_file: Path,
|
||
output_prefix: str,
|
||
method: str = "mafft",
|
||
rscript_path: Optional[Path] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
运行R脚本绘制多序列比对图
|
||
|
||
Args:
|
||
msa_file: 比对好的MSA文件
|
||
output_prefix: 输出文件前缀
|
||
method: 比对方法(用于报告)
|
||
rscript_path: R脚本路径,如果None则自动查找
|
||
|
||
Returns:
|
||
包含绘图结果的字典
|
||
"""
|
||
print(f"📊 绘制MSA比对图: {msa_file}")
|
||
|
||
# 自动查找R脚本
|
||
if rscript_path is None:
|
||
# 尝试多个可能的路径
|
||
script_candidates = [
|
||
Path(__file__).parent.parent / "scripts" / "msa_figure_from_aligned.R",
|
||
Path(__file__).parent.parent.parent / "scripts" / "msa_figure_from_aligned.R",
|
||
Path.cwd() / "scripts" / "msa_figure_from_aligned.R",
|
||
]
|
||
|
||
for candidate in script_candidates:
|
||
if candidate.exists():
|
||
rscript_path = candidate
|
||
break
|
||
|
||
if rscript_path is None:
|
||
# 如果专用脚本不存在,尝试原始脚本
|
||
if method == "mafft":
|
||
rscript_path = Path(__file__).parent.parent / "scripts" / "msa_figure.mafft.R"
|
||
else:
|
||
rscript_path = Path(__file__).parent.parent / "scripts" / "msa_figure.R"
|
||
# ========== 修复点1: 提前定义 output_dir ==========
|
||
output_dir = None
|
||
output_prefix_path = None
|
||
|
||
# 确保输出目录存在
|
||
if rscript_path and rscript_path.exists():
|
||
output_prefix_path = Path(output_prefix)
|
||
output_dir = output_prefix_path.parent
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
# ========== 修复结束 ==========
|
||
|
||
if not rscript_path or not rscript_path.exists():
|
||
return {
|
||
"success": False,
|
||
"error": f"R脚本未找到: {rscript_path}",
|
||
"output_files": []
|
||
}
|
||
|
||
print(f" 使用R脚本: {rscript_path}")
|
||
|
||
# 构建R命令
|
||
rscript_exe = Path(shutil.which("Rscript") or "Rscript")
|
||
if not rscript_exe.exists():
|
||
rscript_exe = Path("Rscript")
|
||
|
||
cmd = [
|
||
str(rscript_exe),
|
||
str(rscript_path),
|
||
display_path(msa_file),
|
||
# str(output_prefix),
|
||
str(output_prefix_path.name), # 只传递文件名,不含路径
|
||
"--method", method
|
||
]
|
||
|
||
# 记录开始时间
|
||
start_time = time.time()
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=output_dir, # 关键:在输出目录中运行R脚本
|
||
timeout=600, # 10分钟超时
|
||
check=False
|
||
)
|
||
|
||
elapsed = time.time() - start_time
|
||
|
||
if result.returncode == 0:
|
||
print(f" ✅ MSA绘图成功,耗时: {elapsed:.1f}秒")
|
||
|
||
# Collect generated image files without constructing invalid glob patterns.
|
||
output_dir = output_prefix_path.parent
|
||
output_stem = output_prefix_path.name
|
||
output_files = []
|
||
for f in output_dir.iterdir():
|
||
if f.is_file() and f.suffix.lower() in {".png", ".pdf"} and f.name.startswith(output_stem):
|
||
output_files.append(str(f))
|
||
output_files = sorted(set(output_files))
|
||
|
||
print(f" 生成 {len(output_files)} 个图片文件在: {output_dir}")
|
||
for i, f in enumerate(output_files[:3], 1):
|
||
print(f" {i}. {Path(f).name}")
|
||
|
||
|
||
return {
|
||
"success": True,
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-500:], # 只保留最后500字符
|
||
"stderr": result.stderr[-500:] if result.stderr else "",
|
||
"output_files": output_files,
|
||
"elapsed_sec": elapsed
|
||
}
|
||
else:
|
||
print(f" ⚠️ MSA绘图失败,返回码: {result.returncode}")
|
||
if result.stderr:
|
||
print(f" 错误信息: {result.stderr[:200]}")
|
||
|
||
return {
|
||
"success": False,
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-500:],
|
||
"stderr": result.stderr[-500:] if result.stderr else "",
|
||
"output_files": [],
|
||
"elapsed_sec": elapsed
|
||
}
|
||
|
||
except subprocess.TimeoutExpired:
|
||
print(f" ❌ MSA绘图超时 (>10分钟)")
|
||
return {
|
||
"success": False,
|
||
"error": "timeout",
|
||
"output_files": []
|
||
}
|
||
except Exception as e:
|
||
print(f" ❌ MSA绘图异常: {e}")
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"output_files": []
|
||
}
|
||
|
||
|
||
|
||
def run_msa_conserved_region_plotting(
|
||
workspace: Path,
|
||
msa_file: Path,
|
||
prefix: str,
|
||
window_len: int = 60,
|
||
min_nongap: float = 0.6,
|
||
scope_file: str = "",
|
||
analysis_route: str = "",
|
||
member_set_id: str = "",
|
||
) -> Dict[str, Any]:
|
||
"""Generate an additional automatically selected conserved-region MSA plot."""
|
||
script = PROJECT_ROOT / "cli" / "run_msa_region_plot.py"
|
||
python_exe = Path(sys.executable)
|
||
cmd = [
|
||
str(python_exe),
|
||
str(script),
|
||
"--workspace", display_path(workspace),
|
||
"--prefix", prefix,
|
||
"--msa-file", display_path(msa_file),
|
||
"--auto-conserved",
|
||
"--window-len", str(window_len),
|
||
"--min-nongap", str(min_nongap),
|
||
"--force",
|
||
]
|
||
if scope_file:
|
||
cmd.extend(["--scope-file", str(scope_file)])
|
||
if analysis_route:
|
||
cmd.extend(["--analysis-route", str(analysis_route)])
|
||
if member_set_id:
|
||
cmd.extend(["--member-set-id", str(member_set_id)])
|
||
start_time = time.time()
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=str(PROJECT_ROOT),
|
||
timeout=900,
|
||
check=False,
|
||
)
|
||
elapsed = time.time() - start_time
|
||
final_dir = workspace / "final"
|
||
output_files = []
|
||
if final_dir.exists():
|
||
for f in final_dir.iterdir():
|
||
if f.is_file() and f.name.startswith(f"{prefix}.msa_conserved_region_"):
|
||
output_files.append(str(f))
|
||
output_files = sorted(set(output_files))
|
||
success = result.returncode == 0 and bool(output_files)
|
||
return {
|
||
"success": success,
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-1000:],
|
||
"stderr": result.stderr[-1000:] if result.stderr else "",
|
||
"output_files": output_files,
|
||
"elapsed_sec": elapsed,
|
||
}
|
||
except subprocess.TimeoutExpired:
|
||
return {"success": False, "error": "timeout", "output_files": []}
|
||
except Exception as exc:
|
||
return {"success": False, "error": str(exc), "output_files": []}
|
||
|
||
|
||
|
||
def infer_species_family_from_output_prefix(prefix: str) -> tuple[str, str]:
|
||
"""Infer Species/Family from output labels such as Lc.SPL.*."""
|
||
parts = [p for p in str(prefix or "").split(".") if p]
|
||
if len(parts) >= 2:
|
||
return parts[0], parts[1]
|
||
return "", ""
|
||
|
||
|
||
def find_cross_scope_subfamily_file(final_dir: Path, prefix: str, input_dir: Path) -> Optional[Path]:
|
||
"""Resolve confirmed labels produced for a target+reference scope.
|
||
|
||
Prediction tables are not auto-detected here. A caller that wants to plot
|
||
unconfirmed prediction labels must pass them explicitly through --subfamily.
|
||
"""
|
||
species, family = infer_species_family_from_output_prefix(prefix)
|
||
candidates: list[Path] = []
|
||
if species and family:
|
||
stem = f"{species}.{family}.target_plus_reference"
|
||
candidates.extend([
|
||
final_dir / f"{stem}.confirmed_subfamily.tsv",
|
||
input_dir / f"{stem}.confirmed_subfamily.tsv",
|
||
])
|
||
for pattern in (
|
||
f"{species}.{family}*target_plus_reference*confirmed_subfamily.tsv",
|
||
f"*target-{species}*target_plus_reference*confirmed_subfamily.tsv",
|
||
):
|
||
candidates.extend(sorted(final_dir.glob(pattern)))
|
||
for candidate in candidates:
|
||
if candidate.exists() and candidate.stat().st_size > 0:
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def run_tree_plotting(
|
||
tree_file: Path,
|
||
output_prefix: Optional[str] = None,
|
||
rscript_path: Optional[Path] = None,
|
||
subfamily_file: Optional[Path] = None,
|
||
disable_subfamily_auto: bool = False,
|
||
tree_layout: str = "both",
|
||
label_offset: float = 0.4,
|
||
tip_label_size: float = 3.0,
|
||
branch_color: str = "black",
|
||
branch_width: float = 0.5,
|
||
show_bootstrap: bool = True,
|
||
bootstrap_size: float = 2.0,
|
||
tip_point_size: float = 2.0,
|
||
target_shape: str = "triangle",
|
||
reference_shape: str = "circle",
|
||
gene_shape: str = "circle",
|
||
marker_mode: str = "auto",
|
||
circular_label_offset: float = 0.8,
|
||
circular_label_size: float = 1.8,
|
||
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]:
|
||
"""Run the R tree plotting script and return the concrete files it generated."""
|
||
print(f"Plot tree: {tree_file}")
|
||
tree_layout = (tree_layout or "both").lower()
|
||
if tree_layout not in {"rectangular", "circular", "both"}:
|
||
return {"success": False, "error": f"Invalid tree layout: {tree_layout}", "output_files": []}
|
||
|
||
if output_prefix is None:
|
||
output_prefix = tree_file.stem
|
||
|
||
prefix = tree_file.stem.replace('.tree', '').replace('.nwk', '')
|
||
|
||
if subfamily_file is None and not disable_subfamily_auto:
|
||
final_dir = tree_file.parent
|
||
workspace_dir = final_dir.parent
|
||
job_id = workspace_dir.name
|
||
project_root = workspace_dir.parent.parent
|
||
input_dir = project_root / "input" / job_id
|
||
potential_confirmed_final = final_dir / f"{prefix}.confirmed_subfamily.tsv"
|
||
potential_confirmed_input = input_dir / f"{prefix}.confirmed_subfamily.tsv"
|
||
potential_cross_scope = find_cross_scope_subfamily_file(final_dir, prefix, input_dir)
|
||
|
||
print(f" [DEBUG] job_id: {job_id}")
|
||
print(f" [DEBUG] project_root: {project_root}")
|
||
print(f" [DEBUG] input_dir: {input_dir}")
|
||
print(f" [DEBUG] lookup confirmed final: {potential_confirmed_final} (exists: {potential_confirmed_final.exists()})")
|
||
print(f" [DEBUG] lookup confirmed input: {potential_confirmed_input} (exists: {potential_confirmed_input.exists()})")
|
||
print(f" [DEBUG] lookup cross-scope confirmed subfamily: {potential_cross_scope} (exists: {bool(potential_cross_scope and potential_cross_scope.exists())})")
|
||
|
||
if potential_confirmed_final.exists():
|
||
subfamily_file = potential_confirmed_final
|
||
print(f" Using confirmed subfamily from final: {subfamily_file}")
|
||
elif potential_confirmed_input.exists():
|
||
subfamily_file = potential_confirmed_input
|
||
print(f" Using confirmed subfamily from input: {subfamily_file}")
|
||
elif potential_cross_scope and potential_cross_scope.exists():
|
||
subfamily_file = potential_cross_scope
|
||
print(f" Using cross-scope confirmed subfamily: {subfamily_file}")
|
||
else:
|
||
print(f" Confirmed subfamily file not found: {prefix}.confirmed_subfamily.tsv")
|
||
|
||
if subfamily_file and subfamily_file.exists():
|
||
script_name = "tree_plot.R"
|
||
use_subfamily = True
|
||
else:
|
||
script_name = "plot_tree.R"
|
||
use_subfamily = False
|
||
|
||
if rscript_path is None:
|
||
script_candidates = [
|
||
Path(__file__).parent.parent / "scripts" / script_name,
|
||
Path(__file__).parent.parent.parent / "scripts" / script_name,
|
||
Path.cwd() / "scripts" / script_name,
|
||
]
|
||
for candidate in script_candidates:
|
||
if candidate.exists():
|
||
rscript_path = candidate
|
||
break
|
||
|
||
if not rscript_path or not rscript_path.exists():
|
||
return {"success": False, "error": f"R script not found: {rscript_path}", "output_files": []}
|
||
|
||
print(f" R script: {rscript_path}")
|
||
print(f" Mode: {'subfamily' if use_subfamily else 'normal'}")
|
||
print(f" Layout: {tree_layout}")
|
||
|
||
tree_dir = tree_file.parent
|
||
style_args_base = [
|
||
str(label_offset),
|
||
str(tip_label_size),
|
||
str(branch_color or "black"),
|
||
str(branch_width),
|
||
"true" if show_bootstrap else "false",
|
||
str(bootstrap_size),
|
||
str(tip_point_size),
|
||
]
|
||
if use_subfamily:
|
||
subfamily_plot_args = [
|
||
*style_args_base,
|
||
str(target_shape or "triangle"),
|
||
str(reference_shape or "circle"),
|
||
str(gene_shape or target_shape or "triangle"),
|
||
str(circular_label_offset),
|
||
str(circular_label_size),
|
||
str(marker_mode or "auto"),
|
||
"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 ""),
|
||
]
|
||
cmd = ["Rscript", str(rscript_path), str(tree_file.name), display_path(subfamily_file), str(output_prefix), tree_layout, "", "", *subfamily_plot_args]
|
||
else:
|
||
normal_plot_args = [
|
||
*style_args_base,
|
||
str(gene_shape or target_shape or "triangle"),
|
||
str(circular_label_offset),
|
||
str(circular_label_size),
|
||
str(marker_mode or "auto"),
|
||
]
|
||
cmd = ["Rscript", str(rscript_path), str(tree_file.name), str(tree_dir), tree_layout, str(output_prefix or ""), *normal_plot_args]
|
||
|
||
start_time = time.time()
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=tree_dir, timeout=600, check=False)
|
||
elapsed = time.time() - start_time
|
||
|
||
if result.returncode == 0:
|
||
print(f" Tree plotting completed, elapsed: {elapsed:.1f}s")
|
||
output_files = []
|
||
if use_subfamily:
|
||
expected_names = [
|
||
f"{output_prefix}.png",
|
||
f"{output_prefix}.pdf",
|
||
f"{output_prefix}.circular_tree.png",
|
||
f"{output_prefix}.circular_tree.pdf",
|
||
]
|
||
for name in expected_names:
|
||
f = tree_dir / name
|
||
if f.exists():
|
||
output_files.append(str(f))
|
||
print(f" 输出: {f.name}")
|
||
else:
|
||
normal_stem = str(output_prefix or tree_file.stem)
|
||
expected_names = [
|
||
f"{normal_stem}.png",
|
||
f"{normal_stem}.pdf",
|
||
f"{normal_stem}.circular_tree.png",
|
||
f"{normal_stem}.circular_tree.pdf",
|
||
]
|
||
for name in expected_names:
|
||
f = tree_dir / name
|
||
if f.exists():
|
||
output_files.append(str(f))
|
||
print(f" 输出: {f.name}")
|
||
output_dir = tree_dir / f"{tree_file.stem}_output"
|
||
if output_dir.exists():
|
||
extra_files = [str(f) for f in output_dir.glob("*") if f.is_file()]
|
||
output_files.extend(extra_files)
|
||
print(f" Generated {len(extra_files)} files in: {output_dir}")
|
||
output_files = sorted(set(output_files))
|
||
return {
|
||
"success": True,
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-500:],
|
||
"stderr": result.stderr[-500:] if result.stderr else "",
|
||
"output_files": output_files,
|
||
"output_dir": str(tree_dir),
|
||
"elapsed_sec": elapsed,
|
||
"mode": "subfamily" if use_subfamily else "normal",
|
||
"subfamily_file": str(subfamily_file) if subfamily_file else None,
|
||
"plot_params": {
|
||
"label_offset": label_offset,
|
||
"tip_label_size": tip_label_size,
|
||
"branch_color": branch_color,
|
||
"branch_width": branch_width,
|
||
"show_bootstrap": show_bootstrap,
|
||
"bootstrap_size": bootstrap_size,
|
||
"tip_point_size": tip_point_size,
|
||
"target_shape": target_shape,
|
||
"reference_shape": reference_shape,
|
||
"gene_shape": gene_shape,
|
||
"marker_mode": marker_mode,
|
||
"circular_label_offset": circular_label_offset,
|
||
"circular_label_size": circular_label_size,
|
||
"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,
|
||
}
|
||
}
|
||
|
||
print(f" Tree plotting failed, return code: {result.returncode}")
|
||
if result.stderr:
|
||
print(f" Error: {stderr[:200]}")
|
||
return {
|
||
"success": False,
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-500:],
|
||
"stderr": result.stderr[-500:] if result.stderr else "",
|
||
"output_files": [],
|
||
"elapsed_sec": elapsed
|
||
}
|
||
except subprocess.TimeoutExpired:
|
||
print(" ? ??????? (>10??)")
|
||
return {"success": False, "error": "timeout", "output_files": []}
|
||
except Exception as e:
|
||
print(f" ? ???????: {e}")
|
||
return {"success": False, "error": str(e), "output_files": []}
|
||
|
||
def extract_best_model(iqtree_output: str) -> str:
|
||
"""从IQ-TREE输出中提取最佳模型"""
|
||
if not iqtree_output:
|
||
return "unknown"
|
||
|
||
lines = iqtree_output.split('\n')
|
||
for line in lines:
|
||
if "Best-fit model:" in line:
|
||
return line.split("Best-fit model:")[1].strip()
|
||
elif "Model of substitution:" in line:
|
||
return line.split("Model of substitution:")[1].strip()
|
||
elif "Best model:" in line:
|
||
return line.split("Best model:")[1].strip()
|
||
|
||
return "unknown"
|
||
|
||
|
||
def write_report(
|
||
workspace: Path,
|
||
prefix: str,
|
||
metrics: Dict[str, Any],
|
||
parameters: Dict[str, Any],
|
||
tool_versions: Dict[str, str],
|
||
mafft_result: Dict[str, Any],
|
||
iqtree_result: Dict[str, Any],
|
||
msa_plot_result: Optional[Dict[str, Any]], # 新增
|
||
tree_plot_result: Optional[Dict[str, Any]], # 新增
|
||
artifacts: Dict[str, Dict]
|
||
) -> Path:
|
||
"""写入人可读的分析报告"""
|
||
report_path = workspace / "final" / f"{prefix}.phylogeny_report.md"
|
||
|
||
print(f"生成分析报告: {report_path}")
|
||
|
||
with open(report_path, "w") as f:
|
||
f.write(f"# Phylogeny分析报告 - {prefix}\n\n")
|
||
f.write(f"**生成时间**: {datetime.now().isoformat()}\n\n")
|
||
|
||
f.write("## 1. 分析概述\n")
|
||
f.write(f"- **分析阶段**: Phylogeny\n")
|
||
f.write(f"- **分析状态**: {'成功' if iqtree_result.get('success', False) else '部分成功'}\n")
|
||
f.write(f"- **序列数量**: {metrics.get('sequences_count', 'N/A')}\n")
|
||
f.write(f"- **比对长度**: {metrics.get('msa_length', 'N/A')} 氨基酸\n")
|
||
f.write(f"- **Gap比例**: {metrics.get('gap_percentage', 'N/A')}%\n")
|
||
f.write(f"- **最佳模型**: {iqtree_result.get('best_model', 'N/A')}\n\n")
|
||
|
||
f.write("## 2. 工具版本\n")
|
||
for tool, version in tool_versions.items():
|
||
f.write(f"- **{tool}**: {version}\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 3. 执行参数\n")
|
||
for param, value in parameters.items():
|
||
f.write(f"- **{param}**: {value}\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 4. 执行命令\n")
|
||
f.write("### MAFFT比对\n")
|
||
f.write(f"```bash\n{mafft_result.get('command', 'N/A')}\n```\n\n")
|
||
|
||
if mafft_result.get('returncode', -1) != 0:
|
||
f.write(f"**返回码**: {mafft_result.get('returncode', 'N/A')}\n")
|
||
if mafft_result.get('stderr'):
|
||
f.write(f"**错误信息**:\n```\n{mafft_result['stderr'][:500]}\n```\n\n")
|
||
|
||
f.write("### IQ-TREE建树\n")
|
||
f.write(f"```bash\n{iqtree_result.get('command', 'N/A')}\n```\n\n")
|
||
|
||
if iqtree_result.get('returncode', -1) != 0:
|
||
f.write(f"**返回码**: {iqtree_result.get('returncode', 'N/A')}\n")
|
||
if iqtree_result.get('stderr'):
|
||
f.write(f"**错误信息**:\n```\n{iqtree_result['stderr'][:500]}\n```\n\n")
|
||
##开始 #
|
||
# MSA绘图结果
|
||
f.write("### 多序列比对图\n")
|
||
if msa_plot_result and msa_plot_result.get("success", False):
|
||
f.write("✅ **MSA比对图绘制成功**\n")
|
||
f.write(f"- 耗时: {msa_plot_result.get('elapsed_sec', 0):.1f}秒\n")
|
||
|
||
# 列出生成的图片
|
||
msa_images = [k for k, v in artifacts.items() if isinstance(v, dict) and v.get("artifact") == "msa_plot"]
|
||
if msa_images:
|
||
f.write("- 生成图片:\n")
|
||
for img_key in msa_images[:5]: # 最多显示5个
|
||
img_info = artifacts[img_key]
|
||
f.write(f" - `{img_info.get('path', '')}` ({img_info.get('size', 0)}字节)\n")
|
||
else:
|
||
f.write("❌ **MSA比对图绘制失败**\n")
|
||
if msa_plot_result:
|
||
f.write(f"- 错误: {msa_plot_result.get('error', '未知错误')}\n")
|
||
|
||
f.write("\n")
|
||
|
||
# 进化树绘图结果
|
||
f.write("### 系统发育树图\n")
|
||
if tree_plot_result and tree_plot_result.get("success", False):
|
||
f.write("✅ **进化树图绘制成功**\n")
|
||
f.write(f"- 耗时: {tree_plot_result.get('elapsed_sec', 0):.1f}秒\n")
|
||
|
||
# 列出生成的图片
|
||
tree_images = [k for k, v in artifacts.items() if isinstance(v, dict) and v.get("artifact") == "tree_plot"]
|
||
if tree_images:
|
||
f.write("- 生成图片:\n")
|
||
for img_key in tree_images[:5]: # 最多显示5个
|
||
img_info = artifacts[img_key]
|
||
f.write(f" - `{img_info.get('path', '')}` ({img_info.get('size', 0)}字节)\n")
|
||
else:
|
||
f.write("❌ **进化树图绘制失败**\n")
|
||
if tree_plot_result:
|
||
f.write(f"- 错误: {tree_plot_result.get('error', '未知错误')}\n")
|
||
|
||
f.write("\n")
|
||
###结束
|
||
|
||
|
||
f.write("## 5. 产出文件\n")
|
||
for artifact_name, artifact_info in artifacts.items():
|
||
if isinstance(artifact_info, dict):
|
||
f.write(f"- **{artifact_name}**: {artifact_info.get('path', 'N/A')}\n")
|
||
if artifact_info.get('size'):
|
||
f.write(f" - 大小: {artifact_info['size']} 字节\n")
|
||
if artifact_info.get('sha256'):
|
||
f.write(f" - SHA256: {artifact_info['sha256'][:16]}...\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 6. 详细统计\n")
|
||
f.write("```json\n")
|
||
f.write(json.dumps(metrics, indent=2, ensure_ascii=False))
|
||
f.write("\n```\n")
|
||
|
||
return report_path
|
||
|
||
|
||
def write_manifest(
|
||
workspace: Path,
|
||
job_id: str,
|
||
run_id: str,
|
||
prefix: str,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
artifacts: Dict[str, Dict],
|
||
metrics: Dict[str, Any],
|
||
parameters: Dict[str, Any],
|
||
tool_versions: Dict[str, str],
|
||
cmd_history: List[str],
|
||
identify_inputs: List[Dict],
|
||
status: str = "success",
|
||
scope_info: Optional[Dict[str, Any]] = None,
|
||
output_prefix: Optional[str] = None,
|
||
analysis_route: str = "",
|
||
member_set_id: str = "",
|
||
subfamily_file: str = "",
|
||
) -> Path:
|
||
"""
|
||
写入符合schema v1的manifest文件
|
||
|
||
注意:artifacts必须是字典格式,不是列表
|
||
"""
|
||
elapsed_sec = (end_time - start_time).total_seconds()
|
||
output_prefix = output_prefix or prefix
|
||
scope_id = str((scope_info or {}).get("scope_id") or "").strip()
|
||
|
||
manifest_data = {
|
||
# 基础标识字段(必需)
|
||
"manifest_version": "1.0",
|
||
"job_id": job_id,
|
||
"stage": "phylogeny",
|
||
"run_id": run_id,
|
||
"prefix": prefix,
|
||
"output_prefix": output_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_history),
|
||
"tool_versions": tool_versions,
|
||
|
||
# 目录路径(必需)
|
||
"workspace_root": display_path(workspace.parent),
|
||
"workspace": display_path(workspace / "final"),
|
||
"input_dir": display_path(workspace.parent.parent / "input" / job_id),
|
||
|
||
# 输入文件(必需)
|
||
"inputs": rebase_manifest_payload_paths(workspace, identify_inputs),
|
||
|
||
# 输出产物(必需)- 必须是字典格式
|
||
"artifacts": artifacts,
|
||
|
||
# 参数和指标(必需)
|
||
"parameters": rebase_manifest_payload_paths(workspace, parameters),
|
||
"metrics": metrics,
|
||
"scope_id": scope_id,
|
||
"scope": rebase_manifest_payload_paths(workspace, scope_info or {}),
|
||
|
||
# 溯源信息(可选但推荐)
|
||
"provenance": {
|
||
"derived_from": f"{prefix}.identify.manifest.json",
|
||
"stage_sequence": ["identify", "phylogeny"]
|
||
}
|
||
}
|
||
context = build_stage_context(
|
||
stage="phylogeny",
|
||
prefix=prefix,
|
||
scope_data=scope_info or {},
|
||
scope_file=(scope_info or {}).get("scope_file", ""),
|
||
output_prefix=output_prefix,
|
||
subfamily_file=subfamily_file,
|
||
analysis_route=analysis_route,
|
||
member_set_id=member_set_id,
|
||
)
|
||
apply_stage_context(manifest_data, context)
|
||
|
||
if scope_id:
|
||
manifest_path = workspace / "final" / f"{output_prefix}.phylogeny.manifest.json"
|
||
manifest_data["scope_manifest"] = display_path(manifest_path)
|
||
else:
|
||
manifest_path = workspace / "final" / f"{prefix}.phylogeny.manifest.json"
|
||
|
||
print(f"生成Manifest文件: {manifest_path}")
|
||
|
||
# 确保JSON严格性:无注释,确保ASCII
|
||
with open(manifest_path, "w", encoding='utf-8') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
|
||
return manifest_path
|
||
|
||
|
||
def main():
|
||
"""主函数:执行Phylogeny分析流程"""
|
||
parser = argparse.ArgumentParser(
|
||
description="Phylogeny阶段:多序列比对和系统发育分析",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
python run_phylogeny.py --prefix Ft.SPL --workspace /path/to/workspace --cpu 4
|
||
|
||
输入要求:
|
||
- Identify阶段必须已成功运行
|
||
- final/{prefix}.final_members.pep.fa 必须存在
|
||
|
||
输出产物:
|
||
- final/{prefix}.pep.msa.fa (多序列比对)
|
||
- final/{prefix}.tree.nwk (系统发育树)
|
||
- final/{prefix}.phylogeny_report.md (分析报告)
|
||
- final/{prefix}.phylogeny.manifest.json (机器清单)
|
||
"""
|
||
)
|
||
|
||
parser.add_argument("--prefix", required=True,
|
||
help="分析前缀,格式:物种.家族(如 Ft.SPL)")
|
||
parser.add_argument("--workspace", required=True,
|
||
help="工作空间目录(workspace/<job_id>/)")
|
||
parser.add_argument("--cpu", type=int, default=8,
|
||
help="CPU核心数(默认:8)")
|
||
parser.add_argument("--proteins", "--protein-fasta",
|
||
dest="proteins",
|
||
help="Protein FASTA override; defaults to Identify manifest final_members protein FASTA")
|
||
parser.add_argument("--scope-file",
|
||
help="Scope JSON; when provided, build phylogeny input FASTA from selected species")
|
||
parser.add_argument("--analysis-route", default="", help="Explicit analysis route identity for manifest/status")
|
||
parser.add_argument("--member-set-id", default="", help="Explicit member-set identity for manifest/status")
|
||
parser.add_argument("--subfamily", default="", help="Optional subfamily table for tree plotting")
|
||
parser.add_argument("--mafft-params",
|
||
help="MAFFT参数覆盖(默认:--auto --thread {cpu})")
|
||
parser.add_argument("--iqtree-params",
|
||
help="IQ-TREE参数覆盖(默认:-m MFP -bb 1000 -nt {cpu};-nt 会被 --cpu 覆盖)")
|
||
parser.add_argument("--tree-layout", choices=["rectangular", "circular", "both"], default="both",
|
||
help="进化树绘图布局:rectangular、circular 或 both(默认:both)")
|
||
parser.add_argument("--tree-subfamily-arc-enabled", choices=["true", "false"], default="true",
|
||
help="圈图是否显示亚家族外圈弧线(默认:true)")
|
||
parser.add_argument("--tree-subfamily-arc-gap", type=float, default=1.2,
|
||
help="圈图亚家族弧线相对基因ID外边界的额外距离(默认:1.2)")
|
||
parser.add_argument("--tree-subfamily-arc-label-gap", type=float, default=0.6,
|
||
help="圈图亚家族名称相对弧线的额外距离(默认:0.6)")
|
||
parser.add_argument("--tree-subfamily-arc-label-size", type=float, default=0.0,
|
||
help="圈图亚家族名称字号;0 表示自动(默认:0)")
|
||
parser.add_argument("--log-file",
|
||
help="日志文件路径(默认:workspace/logs/{timestamp}_phylogeny.log)")
|
||
|
||
args = parser.parse_args()
|
||
|
||
# 记录开始时间
|
||
start_time = datetime.now()
|
||
run_id = f"phylogeny_{start_time.strftime('%Y%m%d_%H%M%S_%f')[:-3]}"
|
||
|
||
# 设置工作空间
|
||
workspace = Path(args.workspace)
|
||
job_id = workspace.name # workspace/<job_id>中的job_id
|
||
effective_prefix = infer_effective_prefix(workspace, args.prefix)
|
||
|
||
print("=" * 60)
|
||
print(f"🚀 启动Phylogeny分析")
|
||
print("=" * 60)
|
||
print(f"分析前缀: {args.prefix}")
|
||
print(f"工作空间: {workspace}")
|
||
print(f"任务ID: {job_id}")
|
||
print(f"运行ID: {run_id}")
|
||
print(f"CPU核心: {args.cpu}")
|
||
print(f"树图布局: {args.tree_layout}")
|
||
|
||
try:
|
||
# 1. 准备目录结构
|
||
print("\n📁 准备目录结构...")
|
||
paths = prepare_directories(workspace)
|
||
|
||
# 2. 获取工具版本
|
||
print("\n🔧 检查工具版本...")
|
||
tool_versions = get_tool_versions()
|
||
print(f" MAFFT: {tool_versions.get('mafft', 'unknown')}")
|
||
print(f" IQ-TREE: {tool_versions.get('iqtree', 'unknown')}")
|
||
|
||
# 3. 验证Identify输出
|
||
print("\n🔍 验证Identify阶段输出...")
|
||
identify_info = validate_identify_output(workspace, effective_prefix)
|
||
protein_fasta = identify_info["protein_fasta"]
|
||
scope_path = Path(args.scope_file).absolute() if args.scope_file else None
|
||
if scope_path and not scope_path.exists():
|
||
raise FileNotFoundError(f"Scope file not found: {scope_path}")
|
||
scope_info = {
|
||
"enabled": False,
|
||
"input_fasta": display_path(protein_fasta),
|
||
"sequence_count": len(read_fasta_records(protein_fasta)),
|
||
}
|
||
if args.proteins:
|
||
protein_override = resolve_job_path(workspace, args.proteins) or Path(args.proteins)
|
||
if not protein_override.exists():
|
||
raise FileNotFoundError(f"Specified protein FASTA not found: {protein_override}")
|
||
if protein_override.stat().st_size == 0:
|
||
raise ValueError(f"Specified protein FASTA is empty: {protein_override}")
|
||
protein_fasta = protein_override
|
||
print(f" 使用指定蛋白序列文件: {protein_fasta}")
|
||
scope_info = {
|
||
"enabled": bool(scope_path),
|
||
"scope_file": str(scope_path) if scope_path else "",
|
||
"input_fasta": display_path(protein_fasta),
|
||
"sequence_count": len(read_fasta_records(protein_fasta)),
|
||
"note": "--proteins override was used; scope selected species were not used to build FASTA",
|
||
}
|
||
elif scope_path:
|
||
print("\n🧭 准备Phylogeny scope输入FASTA...")
|
||
protein_fasta, scope_info = prepare_phylogeny_scope_fasta(
|
||
workspace=workspace,
|
||
prefix=effective_prefix,
|
||
identify_info=identify_info,
|
||
scope_file=scope_path,
|
||
fallback_fasta=protein_fasta,
|
||
analysis_route=args.analysis_route,
|
||
)
|
||
print(f" Scope输入FASTA: {protein_fasta}")
|
||
print(f" Scope序列数: {scope_info.get('sequence_count')}")
|
||
output_prefix_token = effective_prefix
|
||
if scope_info.get("scope_id"):
|
||
output_prefix_token = user_output_prefix_for(
|
||
"phylogeny",
|
||
effective_prefix,
|
||
scope_info,
|
||
analysis_route=args.analysis_route,
|
||
member_set_id=args.member_set_id,
|
||
)
|
||
print(f" 输出前缀: {output_prefix_token}")
|
||
|
||
# 准备Identify的inputs信息
|
||
identify_inputs = []
|
||
if "inputs" in identify_info["identify_manifest"]:
|
||
identify_inputs = identify_info["identify_manifest"]["inputs"]
|
||
|
||
# 4. 运行MAFFT比对
|
||
print("\n🔄 运行MAFFT多序列比对...")
|
||
msa_intermediate = paths["analysis_dir"] / f"{output_prefix_token}.pep.mafft.msa.fa"
|
||
mafft_result = run_mafft_alignment(
|
||
input_fasta=protein_fasta,
|
||
output_msa=msa_intermediate,
|
||
cpu=args.cpu,
|
||
mafft_params=args.mafft_params
|
||
)
|
||
|
||
if not mafft_result.get("success", False):
|
||
print("❌ MAFFT比对失败")
|
||
raise RuntimeError(f"MAFFT失败: {mafft_result.get('stderr', '未知错误')}")
|
||
|
||
msa_metrics = mafft_result.get("metrics", {})
|
||
print(f" 比对成功: {msa_metrics.get('sequences_count', '未知')} 条序列")
|
||
|
||
# 5. 复制MSA到final目录
|
||
msa_final = paths["final_dir"] / f"{output_prefix_token}.pep.msa.fa"
|
||
msa_final.write_bytes(msa_intermediate.read_bytes())
|
||
print(f" MSA保存到: {msa_final}")
|
||
|
||
# 6. 运行IQ-TREE建树
|
||
print("\n🌳 运行IQ-TREE构建系统发育树...")
|
||
iqtree_result = run_iqtree_tree(
|
||
msa_file= paths["final_dir"] / f"{output_prefix_token}.pep.msa.fa",
|
||
output_dir=paths["analysis_dir"],
|
||
prefix=output_prefix_token,
|
||
cpu=args.cpu,
|
||
iqtree_params=args.iqtree_params
|
||
)
|
||
|
||
# 7. 复制树文件到final目录(即使部分成功也尝试)
|
||
tree_final = None
|
||
if iqtree_result.get("tree_file") and iqtree_result["tree_file"].exists():
|
||
tree_final = paths["final_dir"] / f"{output_prefix_token}.tree.nwk"
|
||
tree_final.write_bytes(iqtree_result["tree_file"].read_bytes())
|
||
print(f" 树文件保存到: {tree_final}")
|
||
elif iqtree_result.get("success", False):
|
||
# 尝试查找其他可能的树文件
|
||
tree_files = list(paths["analysis_dir"].glob(f"{output_prefix_token}.iqtree.*"))
|
||
tree_files = [f for f in tree_files if f.suffix in ['.treefile', '.nwk', '.tre']]
|
||
if tree_files:
|
||
tree_final = paths["final_dir"] / f"{output_prefix_token}.tree.nwk"
|
||
tree_final.write_bytes(tree_files[0].read_bytes())
|
||
print(f" 树文件保存到: {tree_final}")
|
||
else:
|
||
print("⚠️ 警告: 未找到树文件,但IQ-TREE报告成功")
|
||
|
||
|
||
# ========== 8. 绘制MSA比对图 ==========
|
||
msa_plot_result = None
|
||
msa_conserved_region_result = None
|
||
tree_plot_result = None
|
||
|
||
# 确保MSA文件路径正确
|
||
msa_final = paths["final_dir"] / f"{output_prefix_token}.pep.msa.fa"
|
||
|
||
if msa_final.exists():
|
||
print("\n" + "="*60)
|
||
print("📸 开始绘制MSA比对图...")
|
||
msa_plot_result = run_msa_plotting(
|
||
msa_file=msa_final,
|
||
output_prefix=paths["final_dir"] / f"{output_prefix_token}.msa_plot",
|
||
method="mafft"
|
||
)
|
||
|
||
if msa_plot_result and msa_plot_result.get("success", False):
|
||
print(f"✅ MSA比对图绘制完成")
|
||
else:
|
||
print(f"⚠️ MSA比对图绘制失败")
|
||
|
||
print("\n📌 自动筛选保守MSA区域并绘图...")
|
||
msa_conserved_region_result = run_msa_conserved_region_plotting(
|
||
workspace=workspace,
|
||
msa_file=msa_final,
|
||
prefix=output_prefix_token,
|
||
window_len=60,
|
||
min_nongap=0.6,
|
||
scope_file=scope_info.get("scope_file", ""),
|
||
analysis_route=args.analysis_route,
|
||
member_set_id=args.member_set_id,
|
||
)
|
||
if msa_conserved_region_result and msa_conserved_region_result.get("success", False):
|
||
print("✅ 保守区MSA图绘制完成")
|
||
else:
|
||
print("⚠️ 保守区MSA图绘制失败")
|
||
|
||
# ========== 9. 绘制进化树图 ==========
|
||
if tree_final and tree_final.exists():
|
||
print("\n" + "="*60)
|
||
print("🌳 开始绘制进化树图...")
|
||
subfamily_arg = str(args.subfamily or "").strip()
|
||
subfamily_disabled = subfamily_arg.lower() in {"none", "skip", "disabled"}
|
||
subfamily_override = None if (not subfamily_arg or subfamily_disabled) else (resolve_job_path(workspace, subfamily_arg) or Path(subfamily_arg)).absolute()
|
||
tree_plot_result = run_tree_plotting(
|
||
tree_file=tree_final,
|
||
output_prefix=f"{output_prefix_token}.tree",
|
||
tree_layout=args.tree_layout,
|
||
subfamily_file=subfamily_override,
|
||
disable_subfamily_auto=subfamily_disabled,
|
||
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 and tree_plot_result.get("success", False):
|
||
print(f"✅ 进化树图绘制完成")
|
||
else:
|
||
print(f"⚠️ 进化树图绘制失败")
|
||
|
||
|
||
|
||
# 8. 确定分析状态
|
||
status = "success"
|
||
if not iqtree_result.get("success", False):
|
||
if mafft_result.get("success", False):
|
||
status = "partial_success"
|
||
print("⚠️ 分析部分成功: MAFFT成功但IQ-TREE失败")
|
||
else:
|
||
status = "failed"
|
||
|
||
# 9. 准备artifacts信息(字典格式)
|
||
artifacts = {}
|
||
|
||
if msa_final.exists():
|
||
artifacts["msa"] = {
|
||
"artifact": "msa",
|
||
"path": f"final/{output_prefix_token}.pep.msa.fa",
|
||
"full_path": display_path(msa_final),
|
||
"type": "fasta",
|
||
"size": msa_final.stat().st_size,
|
||
"sha256": compute_file_hash(msa_final),
|
||
"mtime": msa_final.stat().st_mtime
|
||
}
|
||
|
||
if tree_final and tree_final.exists():
|
||
artifacts["tree"] = {
|
||
"artifact": "tree",
|
||
"path": f"final/{output_prefix_token}.tree.nwk",
|
||
"full_path": display_path(tree_final),
|
||
"type": "newick",
|
||
"size": tree_final.stat().st_size,
|
||
"sha256": compute_file_hash(tree_final),
|
||
"mtime": tree_final.stat().st_mtime
|
||
}
|
||
|
||
# 添加MSA绘图文件到artifacts
|
||
if msa_plot_result and msa_plot_result.get("success", False):
|
||
|
||
# for i, img_file in enumerate(msa_plot_result.get("output_files", []), 1):
|
||
# img_path = Path(img_file)
|
||
# if img_path.exists():
|
||
png_added = False
|
||
for img_file in msa_plot_result.get("output_files", []):
|
||
img_path = Path(img_file)
|
||
if not img_path.exists():
|
||
continue
|
||
|
||
# 只处理 PNG 图片用于 Gallery 显示
|
||
if img_path.suffix.lower() == ".png":
|
||
artifact_key = f"msa_plot_{img_path.stem}"
|
||
artifacts[artifact_key] = {
|
||
"artifact": "msa_plot",
|
||
"path": str(img_path.relative_to(workspace)),
|
||
"full_path": display_path(img_path),
|
||
"type": "image", # 只有 PNG 才是 image
|
||
"format": "png",
|
||
"size": img_path.stat().st_size,
|
||
"sha256": compute_file_hash(img_path),
|
||
"caption": "MSA Alignment"
|
||
}
|
||
png_added = True
|
||
print(f"[DEBUG] Added MSA PNG artifact: {img_path.name}")
|
||
|
||
# PDF 作为 document 类型,不进入 Gallery
|
||
elif img_path.suffix.lower() == ".pdf":
|
||
artifact_key = f"msa_plot_{img_path.stem}_pdf"
|
||
artifacts[artifact_key] = {
|
||
"artifact": "msa_plot_pdf",
|
||
"path": str(img_path.relative_to(workspace)),
|
||
"full_path": display_path(img_path),
|
||
"type": "document", # 不是 image!
|
||
"format": "pdf",
|
||
"size": img_path.stat().st_size,
|
||
"sha256": compute_file_hash(img_path)
|
||
}
|
||
print(f"[DEBUG] Added MSA PDF artifact: {img_path.name}")
|
||
|
||
if not png_added:
|
||
print(f"[WARN] No MSA PNG file found in output_files")
|
||
# artifact_key = f"msa_plot_{i}"
|
||
# if img_path.suffix == ".png":
|
||
# artifact_key += "_png"
|
||
# elif img_path.suffix == ".pdf":
|
||
# artifact_key += "_pdf"
|
||
|
||
# artifacts[artifact_key] = {
|
||
# "artifact": "msa_plot",
|
||
# "path": str(img_path.relative_to(workspace)),
|
||
# "full_path": str(img_path),
|
||
# "type": "image",
|
||
# "format": img_path.suffix[1:], # 去掉点号
|
||
# "size": img_path.stat().st_size,
|
||
# "sha256": compute_file_hash(img_path)
|
||
# }
|
||
|
||
if msa_conserved_region_result and msa_conserved_region_result.get("success", False):
|
||
for output_file in msa_conserved_region_result.get("output_files", []):
|
||
conserved_path = Path(output_file)
|
||
if not conserved_path.exists():
|
||
continue
|
||
suffix = conserved_path.suffix.lower()
|
||
if suffix == ".png":
|
||
key = f"msa_conserved_region_plot_{conserved_path.stem}"
|
||
artifacts[key] = {
|
||
"artifact": "msa_conserved_region_plot",
|
||
"path": str(conserved_path.relative_to(workspace)),
|
||
"full_path": display_path(conserved_path),
|
||
"type": "image",
|
||
"format": "png",
|
||
"size": conserved_path.stat().st_size,
|
||
"sha256": compute_file_hash(conserved_path),
|
||
"caption": "Automatically selected conserved MSA region",
|
||
}
|
||
elif suffix == ".pdf":
|
||
key = f"msa_conserved_region_plot_{conserved_path.stem}_pdf"
|
||
artifacts[key] = {
|
||
"artifact": "msa_conserved_region_plot_pdf",
|
||
"path": str(conserved_path.relative_to(workspace)),
|
||
"full_path": display_path(conserved_path),
|
||
"type": "document",
|
||
"format": "pdf",
|
||
"size": conserved_path.stat().st_size,
|
||
"sha256": compute_file_hash(conserved_path),
|
||
}
|
||
elif suffix in {".tsv", ".fa", ".json"}:
|
||
key = f"msa_conserved_region_{conserved_path.stem}_{suffix[1:]}"
|
||
artifacts[key] = {
|
||
"artifact": "msa_conserved_region_file",
|
||
"path": str(conserved_path.relative_to(workspace)),
|
||
"full_path": display_path(conserved_path),
|
||
"type": "file",
|
||
"format": suffix[1:],
|
||
"size": conserved_path.stat().st_size,
|
||
"sha256": compute_file_hash(conserved_path),
|
||
}
|
||
|
||
# Add tree plot files returned by run_tree_plotting only.
|
||
if tree_plot_result and tree_plot_result.get("success", False):
|
||
for output_file in tree_plot_result.get("output_files", []):
|
||
plot_path = Path(output_file)
|
||
if not plot_path.exists():
|
||
continue
|
||
if plot_path.suffix.lower() == ".png":
|
||
key = f"tree_plot_{plot_path.stem}"
|
||
artifacts[key] = {
|
||
"artifact": "tree_plot",
|
||
"path": str(plot_path.relative_to(workspace)),
|
||
"full_path": display_path(plot_path),
|
||
"type": "image",
|
||
"format": "png",
|
||
"size": plot_path.stat().st_size,
|
||
"sha256": compute_file_hash(plot_path),
|
||
"caption": "Tree Plot"
|
||
}
|
||
elif plot_path.suffix.lower() == ".pdf":
|
||
key = f"tree_plot_{plot_path.stem}_pdf"
|
||
artifacts[key] = {
|
||
"artifact": "tree_plot_pdf",
|
||
"path": str(plot_path.relative_to(workspace)),
|
||
"full_path": display_path(plot_path),
|
||
"type": "document",
|
||
"format": "pdf",
|
||
"size": plot_path.stat().st_size,
|
||
"sha256": compute_file_hash(plot_path)
|
||
}
|
||
if tree_plot_result.get("output_dir"):
|
||
output_dir = Path(tree_plot_result["output_dir"])
|
||
if output_dir.exists():
|
||
artifacts["tree_plot_output_dir"] = {
|
||
"artifact": "tree_plot_directory",
|
||
"path": str(output_dir.relative_to(workspace)),
|
||
"full_path": display_path(output_dir),
|
||
"type": "directory",
|
||
"file_count": len(list(output_dir.glob("*")))
|
||
}
|
||
|
||
# 10. ??metrics
|
||
combined_metrics = {}
|
||
if msa_metrics:
|
||
combined_metrics.update(msa_metrics)
|
||
|
||
if iqtree_result.get("best_model"):
|
||
combined_metrics["tree_model"] = iqtree_result["best_model"]
|
||
|
||
if mafft_result.get("metrics", {}).get("alignment_time_sec"):
|
||
combined_metrics["alignment_time_sec"] = mafft_result["metrics"]["alignment_time_sec"]
|
||
|
||
if iqtree_result.get("tree_building_time_sec"):
|
||
combined_metrics["tree_building_time_sec"] = iqtree_result["tree_building_time_sec"]
|
||
if tree_final and tree_final.exists():
|
||
combined_metrics["tree_tip_count"] = count_tree_tips(tree_final)
|
||
|
||
# 添加绘图相关metrics
|
||
if msa_plot_result:
|
||
combined_metrics["msa_plotting_success"] = msa_plot_result.get("success", False)
|
||
if msa_plot_result.get("elapsed_sec"):
|
||
combined_metrics["msa_plotting_time_sec"] = msa_plot_result["elapsed_sec"]
|
||
|
||
if msa_conserved_region_result:
|
||
combined_metrics["msa_conserved_region_success"] = msa_conserved_region_result.get("success", False)
|
||
if msa_conserved_region_result.get("elapsed_sec"):
|
||
combined_metrics["msa_conserved_region_time_sec"] = msa_conserved_region_result["elapsed_sec"]
|
||
|
||
if tree_plot_result:
|
||
combined_metrics["tree_plotting_success"] = tree_plot_result.get("success", False)
|
||
if tree_plot_result.get("elapsed_sec"):
|
||
combined_metrics["tree_plotting_time_sec"] = tree_plot_result["elapsed_sec"]
|
||
if tree_plot_result.get("subfamily_file"):
|
||
combined_metrics["tree_subfamily_file"] = tree_plot_result["subfamily_file"]
|
||
combined_metrics["tree_plot_mode"] = tree_plot_result.get("mode", "")
|
||
combined_metrics["scope_sequence_count"] = scope_info.get("sequence_count")
|
||
if scope_info.get("species_counts"):
|
||
combined_metrics["scope_species_counts"] = scope_info.get("species_counts")
|
||
|
||
|
||
# 11. 准备parameters
|
||
parameters = {
|
||
"cpu": args.cpu,
|
||
"prefix": effective_prefix,
|
||
"output_prefix": output_prefix_token,
|
||
"mafft_params": args.mafft_params or "--auto --thread {cpu}",
|
||
"iqtree_params": args.iqtree_params or f"-m MFP -bb 1000 -nt {args.cpu}",
|
||
"tree_layout": args.tree_layout,
|
||
"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,
|
||
"mafft_version": tool_versions.get("mafft", "unknown"),
|
||
"iqtree_version": tool_versions.get("iqtree", "unknown"),
|
||
"stage": "phylogeny",
|
||
"job_id": job_id,
|
||
"run_id": run_id,
|
||
"manifest_version": "1.0",
|
||
"status": status,
|
||
"scope_id": scope_info.get("scope_id", ""),
|
||
"scope_file": scope_info.get("scope_file", ""),
|
||
"scope_input_fasta": scope_info.get("input_fasta", ""),
|
||
"tree_subfamily_file": tree_plot_result.get("subfamily_file") if tree_plot_result else "",
|
||
"tree_subfamily_disabled": bool(locals().get("subfamily_disabled", False)),
|
||
"tree_plot_mode": tree_plot_result.get("mode") if tree_plot_result else "",
|
||
}
|
||
|
||
# 12. 记录命令历史
|
||
cmd_history = []
|
||
if mafft_result.get("command"):
|
||
cmd_history.append(mafft_result["command"])
|
||
if iqtree_result.get("command"):
|
||
cmd_history.append(iqtree_result["command"])
|
||
|
||
# 13. 写入报告
|
||
print("\n📝 生成分析报告...")
|
||
report_path = write_report(
|
||
workspace=workspace,
|
||
prefix=output_prefix_token,
|
||
metrics=combined_metrics,
|
||
parameters=parameters,
|
||
tool_versions=tool_versions,
|
||
mafft_result=mafft_result,
|
||
iqtree_result=iqtree_result,
|
||
msa_plot_result=msa_plot_result, # 新增
|
||
tree_plot_result=tree_plot_result, # 新增
|
||
artifacts=artifacts
|
||
)
|
||
|
||
# 添加报告到artifacts
|
||
if report_path.exists():
|
||
artifacts["report"] = {
|
||
"artifact": "report",
|
||
"path": f"final/{output_prefix_token}.phylogeny_report.md",
|
||
"full_path": display_path(report_path),
|
||
"type": "markdown",
|
||
"size": report_path.stat().st_size,
|
||
"sha256": compute_file_hash(report_path)
|
||
}
|
||
|
||
# 14. 写入manifest
|
||
print("\n📋 生成Manifest文件...")
|
||
end_time = datetime.now()
|
||
manifest_path = write_manifest(
|
||
workspace=workspace,
|
||
job_id=job_id,
|
||
run_id=run_id,
|
||
prefix=effective_prefix,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
artifacts=artifacts,
|
||
metrics=combined_metrics,
|
||
parameters=parameters,
|
||
tool_versions=tool_versions,
|
||
cmd_history=cmd_history,
|
||
identify_inputs=identify_inputs,
|
||
status=status,
|
||
scope_info=scope_info,
|
||
output_prefix=output_prefix_token,
|
||
analysis_route=args.analysis_route,
|
||
member_set_id=args.member_set_id,
|
||
subfamily_file=tree_plot_result.get("subfamily_file") if tree_plot_result else "",
|
||
)
|
||
|
||
# 添加manifest自身到artifacts
|
||
if manifest_path.exists():
|
||
artifacts["manifest"] = {
|
||
"artifact": "manifest",
|
||
"path": str(manifest_path.relative_to(workspace)),
|
||
"full_path": display_path(manifest_path),
|
||
"type": "json",
|
||
"size": manifest_path.stat().st_size,
|
||
"sha256": compute_file_hash(manifest_path)
|
||
}
|
||
|
||
# 15. 更新manifest包含完整的artifacts
|
||
if manifest_path.exists():
|
||
with open(manifest_path, 'r') as f:
|
||
manifest_data = json.load(f)
|
||
manifest_data["artifacts"] = artifacts
|
||
with open(manifest_path, 'w') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
if scope_info.get("scope_id"):
|
||
legacy_manifest = write_legacy_manifest_copy(manifest_path, effective_prefix, str(scope_info.get("scope_id") or ""), "phylogeny")
|
||
if legacy_manifest:
|
||
print(f" Legacy scoped manifest: {legacy_manifest}")
|
||
|
||
# 16. 输出成功信息
|
||
elapsed = (end_time - start_time).total_seconds()
|
||
print("\n" + "=" * 60)
|
||
print(f"✅ Phylogeny分析完成!")
|
||
print(f" 总耗时: {elapsed:.1f} 秒")
|
||
print(f" 状态: {status}")
|
||
print(f" Manifest: {manifest_path}")
|
||
print(f" 报告: {report_path}")
|
||
|
||
if msa_final.exists():
|
||
print(f" MSA: {msa_final}")
|
||
|
||
if tree_final and tree_final.exists():
|
||
print(f" 系统发育树: {tree_final}")
|
||
|
||
# 添加绘图文件信息
|
||
if msa_plot_result and msa_plot_result.get("success", False):
|
||
output_files = msa_plot_result.get("output_files", [])
|
||
print(f" MSA比对图: {len(output_files)}个文件")
|
||
for i, f in enumerate(output_files[:3], 1): # 最多显示3个
|
||
print(f" {i}. {Path(f).name}")
|
||
|
||
if tree_plot_result and tree_plot_result.get("success", False):
|
||
output_files = tree_plot_result.get("output_files", [])
|
||
print(f" 进化树图: {len(output_files)}个文件")
|
||
for i, f in enumerate(output_files[:3], 1): # 最多显示3个
|
||
print(f" {i}. {Path(f).name}")
|
||
|
||
|
||
print("=" * 60)
|
||
|
||
# 根据状态返回退出码
|
||
if status == "success":
|
||
sys.exit(0)
|
||
elif status == "partial_success":
|
||
sys.exit(1) # 部分成功返回1
|
||
else:
|
||
sys.exit(2) # 失败返回2
|
||
|
||
except Exception as e:
|
||
# 记录结束时间
|
||
end_time = datetime.now()
|
||
|
||
print(f"\n❌ Phylogeny分析失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
try:
|
||
# 创建基本的artifacts(可能部分成功)
|
||
artifacts = {}
|
||
failure_scope_info = locals().get("scope_info")
|
||
if not failure_scope_info and args.scope_file:
|
||
failure_scope_info = {
|
||
"enabled": True,
|
||
"scope_file": display_path(args.scope_file),
|
||
}
|
||
try:
|
||
scope_data = json.loads(Path(args.scope_file).read_text(encoding="utf-8"))
|
||
failure_scope_info.update({
|
||
"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", []),
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
# 尝试写入失败manifest
|
||
manifest_path = write_manifest(
|
||
workspace=workspace,
|
||
job_id=job_id,
|
||
run_id=run_id,
|
||
prefix=effective_prefix,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
artifacts=artifacts,
|
||
metrics={},
|
||
parameters={
|
||
"cpu": args.cpu,
|
||
"prefix": effective_prefix,
|
||
"error": str(e)[:200],
|
||
"subfamily_file": "" if str(args.subfamily or "").strip().lower() in {"none", "skip", "disabled"} else (args.subfamily or "")
|
||
},
|
||
tool_versions=tool_versions if 'tool_versions' in locals() else {},
|
||
cmd_history=[],
|
||
identify_inputs=identify_inputs if 'identify_inputs' in locals() else [],
|
||
status="failed",
|
||
scope_info=failure_scope_info,
|
||
output_prefix=locals().get("output_prefix_token", effective_prefix),
|
||
analysis_route=args.analysis_route,
|
||
member_set_id=args.member_set_id,
|
||
subfamily_file="",
|
||
)
|
||
|
||
print(f" 失败Manifest已写入: {manifest_path}")
|
||
|
||
except Exception as manifest_error:
|
||
print(f" 写入失败Manifest时出错: {manifest_error}")
|
||
|
||
sys.exit(2)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|