Files
PlantAI-rag-new/bioflow_kernel/cli/run_phylogeny.py
T

2382 lines
96 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()