Files
PlantAI-dev/apps/cli/run_synteny.py
T
2026-08-03 18:42:47 +08:00

2501 lines
86 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
"""
Synteny阶段:对基因家族进行种内共线性分析
输入:
- Identify阶段的蛋白序列文件
- GFF3注释文件
- 基因组文件
输出:
- {prefix}.blast (BLAST比对结果)
- {prefix}.gff (过滤后的GFF)
- {prefix}.dup_class.log (复制基因分类)
- {prefix}.collinearity (MCScanX共线性结果)
- {prefix}.synteny_report.md (分析报告)
- {prefix}.synteny.manifest.json (机器清单)
"""
import argparse
import gzip
import json
import os
import subprocess
import sys
import time
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import hashlib
import re
try:
from gfa_core.gene_family_scope import read_scope, sanitize_token
except Exception:
def read_scope(scope_path):
return json.loads(Path(scope_path).read_text(encoding="utf-8"))
def sanitize_token(value: str) -> str:
token = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip())
token = token.strip(".-_")
return token or "NA"
# 添加conda环境bin目录到PATH
CONDA_BIN = "/home/xzq/miniconda3/envs/test4py311/bin"
if CONDA_BIN not in os.environ.get("PATH", ""):
os.environ["PATH"] = CONDA_BIN + ":" + os.environ.get("PATH", "")
def 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 _relative_to_workspace(path: Path, workspace: Path) -> str:
try:
return str(path.resolve().relative_to(workspace.resolve()))
except Exception:
return str(path)
def _scoped_copy_destination(
source: Path,
workspace: Path,
prefix: str,
scope_id: str,
artifact_name: str,
) -> Path:
scope_token = sanitize_token(scope_id)
suffix = source.suffix
lower_parts = {part.lower() for part in source.parts}
if "final" in lower_parts:
dest_dir = workspace / "final"
else:
dest_dir = workspace / "synteny" / "analysis"
return dest_dir / f"{prefix}.{scope_token}.{sanitize_token(artifact_name)}{suffix}"
def materialize_scoped_artifacts(
artifacts: Dict[str, Dict],
workspace: Path,
prefix: str,
scope_id: str,
) -> Dict[str, Dict]:
"""Copy scope-specific outputs to stable names so later species runs cannot overwrite them."""
if not scope_id:
return artifacts
scoped: Dict[str, Dict] = {}
for name, info in artifacts.items():
if not isinstance(info, dict):
scoped[name] = info
continue
if name == "manifest":
scoped[name] = dict(info)
continue
source_text = info.get("full_path") or info.get("path") or ""
source = Path(source_text)
if not source.is_absolute():
source = workspace / source
if not source.exists():
scoped[name] = dict(info)
continue
dest = _scoped_copy_destination(source, workspace, prefix, scope_id, name)
dest.parent.mkdir(parents=True, exist_ok=True)
if source.absolute() != dest.absolute():
shutil.copy2(source, dest)
copied = dict(info)
copied["path"] = _relative_to_workspace(dest, workspace)
copied["full_path"] = str(dest.absolute())
copied["size"] = dest.stat().st_size
copied["sha256"] = compute_file_hash(dest)
scoped[name] = copied
return scoped
def current_input_dir(workspace: Path) -> Path:
return workspace.parent.parent / "input" / workspace.name
def rebase_current_job_path(workspace: Path, raw_path: Any) -> Optional[Path]:
"""Map copied-job paths from old scope/manifest records back into this job."""
path_text = str(raw_path or "").strip()
if not path_text:
return None
path = Path(path_text)
job_id = workspace.name
input_dir = current_input_dir(workspace)
parts = list(path.parts)
for marker, root in (("input", input_dir), ("workspace", workspace)):
if marker in parts:
idx = parts.index(marker)
if idx + 1 < len(parts) and parts[idx + 1] == job_id:
suffix = parts[idx + 2 :]
candidate = root.joinpath(*suffix)
if candidate.exists():
return candidate
if not path.is_absolute():
for candidate in (Path.cwd() / path, workspace / path):
if candidate.exists():
return candidate
by_name_candidates = [
input_dir / path.name,
workspace / "final" / path.name,
workspace / "synteny" / "analysis" / path.name,
]
for candidate in by_name_candidates:
if candidate.exists():
return candidate
if path.exists():
return path
return None
def rebase_job_path_text(workspace: Path, value: str) -> str:
job_id = re.escape(workspace.name)
pattern = re.compile(rf"/[^\s\"']*?(?:input|workspace)/{job_id}/[^\s\"']+")
def replace_match(match: re.Match[str]) -> str:
original = match.group(0).rstrip(",)")
suffix = match.group(0)[len(original):]
rebased = rebase_current_job_path(workspace, original)
if rebased:
return str(rebased.absolute()) + suffix
return match.group(0)
return pattern.sub(replace_match, value)
def rebase_manifest_payload_paths(workspace: Path, value: Any) -> Any:
if isinstance(value, dict):
return {key: rebase_manifest_payload_paths(workspace, item) for key, item in value.items()}
if isinstance(value, list):
return [rebase_manifest_payload_paths(workspace, item) for item in value]
if isinstance(value, str):
return rebase_job_path_text(workspace, value)
return value
def get_tool_versions() -> Dict[str, str]:
"""获取所需工具版本信息"""
versions = {}
# diamond
try:
result = subprocess.run(
["diamond", "--version"],
capture_output=True,
text=True,
check=False,
timeout=5
)
output = result.stdout.strip() or result.stderr.strip()
versions["diamond"] = output.split('\n')[0] if output else "unknown"
except Exception:
versions["diamond"] = "not_found"
# blastp
try:
result = subprocess.run(
["blastp", "-version"],
capture_output=True,
text=True,
check=False,
timeout=5
)
output = result.stdout.strip() or result.stderr.strip()
versions["blastp"] = output.split('\n')[0] if output else "unknown"
except Exception:
versions["blastp"] = "not_found"
# MCScanX
try:
result = subprocess.run(
["MCScanX", "-h"],
capture_output=True,
text=True,
check=False,
timeout=5
)
versions["MCScanX"] = "available" if result.returncode in [0, 1] else "not_found"
except Exception:
versions["MCScanX"] = "not_found"
# duplicate_gene_classifier
try:
result = subprocess.run(
["duplicate_gene_classifier", "-h"],
capture_output=True,
text=True,
check=False,
timeout=5
)
versions["duplicate_gene_classifier"] = "available" if result.returncode in [0, 1] else "not_found"
except Exception:
versions["duplicate_gene_classifier"] = "not_found"
# circos
try:
result = subprocess.run(
["circos", "-version"],
capture_output=True,
text=True,
check=False,
timeout=5
)
output = result.stdout.strip() or result.stderr.strip()
versions["circos"] = output.split('\n')[0] if output else "available"
except Exception:
versions["circos"] = "not_found"
return versions
def validate_inputs(
workspace: Path,
prefix: str,
input_dir: Path,
gff3: Optional[Path] = None,
genome: Optional[Path] = None,
protein_fasta: Optional[Path] = None,
) -> Dict[str, Any]:
"""验证输入文件"""
print(f"正在验证输入文件...")
results = {
"protein_fasta": None,
"gff3": None,
"genome": None,
"gene_id_file": None
}
# 1. 查找蛋白序列文件。Synteny/MCScanX 需要目标物种全蛋白组,
# scope 模式或 input 中的 *.pep.* 应优先于 identify 的家族蛋白。
if protein_fasta:
protein_path = Path(protein_fasta)
if not protein_path.exists():
raise FileNotFoundError(f"蛋白组文件不存在: {protein_fasta}")
results["protein_fasta"] = protein_path
species_prefix = prefix.split(".")[0] if "." in prefix else prefix
if not results["protein_fasta"]:
for pattern in [
f"{species_prefix}.pep.fasta",
f"{species_prefix}.pep.fa",
"*.pep.fasta",
"*.pep.fa",
f"{prefix}*.fa",
f"{prefix}*.fasta",
"*.fasta"
]:
files = list(input_dir.glob(pattern))
if files:
results["protein_fasta"] = files[0]
break
if not results["protein_fasta"]:
identify_manifest = workspace / "final" / f"{prefix}.identify.manifest.json"
if identify_manifest.exists():
with open(identify_manifest, 'r') as f:
m = json.load(f)
arts = m.get("artifacts", {})
if isinstance(arts, dict):
protein_path = arts.get("final_proteins", {}).get("full_path")
if protein_path and Path(protein_path).exists():
results["protein_fasta"] = Path(protein_path)
if not results["protein_fasta"]:
raise FileNotFoundError("找不到目标物种全蛋白组 FASTA")
# 2. GFF3 文件
if gff3:
gff3_path = Path(gff3)
if not gff3_path.exists():
raise FileNotFoundError(f"GFF3文件不存在: {gff3}")
results["gff3"] = gff3_path
else:
# 自动查找
for pattern in ["*.gff3", "*.gff"]:
files = list(input_dir.glob(pattern))
if files:
results["gff3"] = files[0]
break
if not results["gff3"]:
raise FileNotFoundError("找不到GFF3注释文件")
print(f" GFF3: {results['gff3']}")
# 3. 基因组文件
if genome:
genome_path = Path(genome)
if not genome_path.exists():
raise FileNotFoundError(f"基因组文件不存在: {genome}")
results["genome"] = genome_path
else:
# 自动查找
for pattern in ["*.genome.fasta", "*.genome.fa", "*.fa", "*.fasta"]:
files = list(input_dir.glob(pattern))
if files:
results["genome"] = files[0]
break
if not results["genome"]:
raise FileNotFoundError("找不到基因组文件")
print(f" 基因组: {results['genome']}")
# 4. 基因ID列表文件(可选)
final_id = workspace / "final" / f"{prefix}.final_members.id"
if final_id.exists():
results["gene_id_file"] = final_id
for pattern in ["*.geneID*", "*.gene_id*"]:
if results["gene_id_file"]:
break
files = list(input_dir.glob(pattern))
if files:
results["gene_id_file"] = files[0]
break
if results["gene_id_file"]:
print(f" 基因ID文件: {results['gene_id_file']}")
return results
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") or {}).get(species) or {}
for file_type in file_types:
for record in species_files.get(file_type, []) or []:
path_text = record.get("path") or record.get("full_path") or ""
if path_text:
path = rebase_current_job_path(workspace, path_text)
if path and path.exists():
return path
return None
def first_existing_path(candidates: List[Path]) -> Optional[Path]:
for candidate in candidates:
if candidate.exists() and candidate.is_file() and candidate.stat().st_size > 0:
return candidate
return None
def prefer_representative_target_file(
input_dir: Path,
species: str,
file_type: str,
fallback: Optional[Path],
) -> tuple[Optional[Path], str]:
"""Prefer genome_prepare/standardized representative-transcript files for MCScanX.
Scope records preserve uploaded raw files, which may include multiple
isoforms per locus. MCScanX should receive one representative protein/GFF
per gene whenever the standardized prepare/genome_prepare outputs exist.
"""
species = str(species or "").strip()
if not species:
return fallback, "scope" if fallback else "missing"
if file_type == "pep":
candidates = [
input_dir / f"{species}.pep.fasta",
input_dir / f"{species}.pep.fa",
input_dir / species / f"{species}.pep.fasta",
input_dir / species / f"{species}.pep.fa",
input_dir / "genome_prepare" / f"{species}_extracted.pep.fa",
input_dir / "genome_prepare" / f"{species}.pep.fasta",
input_dir / "genome_prepare" / f"{species}.pep.fa",
]
elif file_type == "gff":
candidates = [
input_dir / f"{species}_final.gff3",
input_dir / f"{species}_final.gff",
input_dir / f"{species}.gff3",
input_dir / f"{species}.gff",
input_dir / species / f"{species}.gff3",
input_dir / species / f"{species}.gff",
input_dir / "genome_prepare" / f"{species}.gff3.tmp",
input_dir / "genome_prepare" / f"{species}_final.gff3",
]
elif file_type == "genome":
candidates = [
input_dir / f"{species}.genome.fasta",
input_dir / f"{species}.genome.fa",
input_dir / f"{species}.genome.fasta.gz",
input_dir / f"{species}.genome.fa.gz",
input_dir / f"{species}.fasta",
input_dir / f"{species}.fa",
input_dir / species / f"{species}.genome.fasta",
input_dir / species / f"{species}.genome.fa",
input_dir / species / f"{species}.genome.fasta.gz",
input_dir / species / f"{species}.genome.fa.gz",
input_dir / species / f"{species}.fasta",
input_dir / species / f"{species}.fa",
input_dir / "genome_prepare" / f"{species}.genome.fasta",
input_dir / "genome_prepare" / f"{species}.genome.fa",
input_dir / "genome_prepare" / f"{species}.fasta",
input_dir / "genome_prepare" / f"{species}.fa",
]
else:
return fallback, "scope" if fallback else "missing"
representative = first_existing_path(candidates)
if representative:
return representative, "representative"
return fallback, "scope_raw_fallback" if fallback else "missing"
def resolve_synteny_scope_inputs(
workspace: Path,
scope_file: Optional[Path],
) -> tuple[Dict[str, Any], Optional[Path], Optional[Path], Optional[Path]]:
if not scope_file:
return {}, None, None, None
scope_data = read_scope(scope_file)
targets = [str(x).strip() for x in (scope_data.get("target_species") or []) if str(x).strip()]
if len(targets) != 1:
raise RuntimeError(
"Synteny 阶段是种内共线性,一次只能分析一个目标物种;"
f"当前 scope target_species={targets or '空'}。请在弹窗中只选择一个 target,或分别运行每个目标物种。"
)
species = targets[0]
protein_fasta = first_existing_scope_file(workspace, scope_data, species, ["pep"])
gff3_file = first_existing_scope_file(workspace, scope_data, species, ["gff"])
genome_file = first_existing_scope_file(workspace, scope_data, species, ["genome"])
if not protein_fasta:
raise RuntimeError(f"Synteny scope 未找到目标物种 {species} 的全蛋白组 pep FASTA")
if not gff3_file:
raise RuntimeError(f"Synteny scope 未找到目标物种 {species} 的 GFF/GFF3 文件")
if not genome_file:
raise RuntimeError(f"Synteny scope 未找到目标物种 {species} 的 genome FASTA 文件")
return scope_data, protein_fasta, gff3_file, genome_file
def open_text_maybe_gzip(path: Path):
if str(path).endswith(".gz"):
return gzip.open(path, "rt", encoding="utf-8", errors="replace")
return path.open("r", encoding="utf-8", errors="replace")
def write_blast_safe_protein_fasta(input_fasta: Path, output_fasta: Path) -> tuple[Path, Dict[str, int]]:
"""Write a DIAMOND/BLAST-safe protein FASTA.
Whole proteomes produced by different preparation tools may contain terminal
stop markers (`*` or `.`) or other non-amino-acid characters. DIAMOND rejects
those records during makedb, causing the pipeline to fall back to very slow
BLASTP. For synteny, remove non-letter characters while keeping the first
FASTA header token as the stable protein ID.
"""
output_fasta.parent.mkdir(parents=True, exist_ok=True)
records = 0
removed_chars = 0
total_aa = 0
header = ""
seq_parts: list[str] = []
def flush_record(out_handle) -> None:
nonlocal records, removed_chars, total_aa, header, seq_parts
if not header:
return
raw_seq = "".join(seq_parts)
clean_seq = "".join(ch for ch in raw_seq.upper() if ch.isalpha())
removed_chars += len(raw_seq) - len(clean_seq)
if clean_seq:
out_handle.write(f">{header}\n")
for i in range(0, len(clean_seq), 80):
out_handle.write(clean_seq[i:i + 80] + "\n")
records += 1
total_aa += len(clean_seq)
header = ""
seq_parts = []
with open_text_maybe_gzip(input_fasta) as in_handle, output_fasta.open("w", encoding="utf-8") as out_handle:
for raw in in_handle:
line = raw.strip()
if not line:
continue
if line.startswith(">"):
flush_record(out_handle)
header = line[1:].split()[0]
seq_parts = []
else:
seq_parts.append(line)
flush_record(out_handle)
if records == 0:
raise RuntimeError(f"No valid protein records after cleaning: {input_fasta}")
return output_fasta, {
"records": records,
"removed_chars": removed_chars,
"total_aa": total_aa,
}
def prepare_directories(workspace: Path) -> Dict[str, Path]:
"""创建Synteny阶段的标准目录结构"""
paths = {
"analysis_dir": workspace / "synteny" / "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 run_diamond_blastp(
input_fasta: Path,
output_blast: Path,
cpu: int = 8,
evalue: float = 1e-10,
max_target_seqs: int = 5
) -> Dict[str, Any]:
"""运行diamond blastp比对"""
print(f"运行Diamond BLASTP: {input_fasta} -> {output_blast}")
# 构建数据库
db_path = input_fasta.with_suffix('')
db_cmd = [
"diamond", "makedb",
"--in", str(input_fasta),
"--db", str(db_path)
]
start_time = time.time()
# 创建数据库
try:
result = subprocess.run(
db_cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False
)
except FileNotFoundError:
print(f" Diamond未安装,无法使用")
return {
"success": False,
"command": " ".join(db_cmd),
"stderr": "diamond not found"
}
if result.returncode != 0:
print(f" Diamond makedb 失败: {result.stderr}")
return {
"success": False,
"command": " ".join(db_cmd),
"stderr": result.stderr
}
# 运行比对
blast_cmd = [
"diamond", "blastp",
"--query", str(input_fasta),
"--db", str(db_path),
"--out", str(output_blast),
"--outfmt", "6",
"--max-target-seqs", str(max_target_seqs),
"--evalue", str(evalue),
"--threads", str(cpu)
]
try:
result = subprocess.run(
blast_cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False
)
except FileNotFoundError:
print(f" Diamond未安装,无法使用")
return {
"success": False,
"command": " ".join(blast_cmd),
"stderr": "diamond not found"
}
elapsed = time.time() - start_time
if result.returncode == 0:
print(f" Diamond成功完成,耗时: {elapsed:.1f}秒")
return {
"success": True,
"command": " ".join(blast_cmd),
"returncode": result.returncode,
"stderr": result.stderr,
"output_file": output_blast,
"elapsed_sec": elapsed
}
else:
print(f" Diamond失败,返回码: {result.returncode}")
return {
"success": False,
"command": " ".join(blast_cmd),
"returncode": result.returncode,
"stderr": result.stderr,
"output_file": None,
"elapsed_sec": elapsed
}
def run_blastp(
input_fasta: Path,
output_blast: Path,
cpu: int = 8,
evalue: float = 1e-10,
max_alignments: int = 5
) -> Dict[str, Any]:
"""运行 NCBI blastp 比对(备选方案)"""
print(f"运行NCBI BLASTP: {input_fasta} -> {output_blast}")
# 构建数据库
db_path = input_fasta.with_suffix('')
db_cmd = [
"makeblastdb",
"-in", str(input_fasta),
"-dbtype", "prot",
"-out", str(db_path)
]
start_time = time.time()
# 创建数据库
try:
result = subprocess.run(
db_cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False
)
except FileNotFoundError:
print(f" makeblastdb未安装,无法使用NCBI blastp")
return {
"success": False,
"command": " ".join(db_cmd),
"stderr": "makeblastdb not found - NCBI BLAST+ not installed"
}
if result.returncode != 0:
print(f" makeblastdb 失败: {result.stderr}")
return {
"success": False,
"command": " ".join(db_cmd),
"stderr": result.stderr
}
# 运行比对
blast_cmd = [
"blastp",
"-query", str(input_fasta),
"-db", str(db_path),
"-outfmt", "6",
"-out", str(output_blast),
"-num_threads", str(cpu),
"-num_alignments", str(max_alignments),
"-evalue", str(evalue)
]
result = subprocess.run(
blast_cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False
)
elapsed = time.time() - start_time
if result.returncode == 0:
print(f" BLASTP成功完成,耗时: {elapsed:.1f}秒")
return {
"success": True,
"command": " ".join(blast_cmd),
"returncode": result.returncode,
"stderr": result.stderr,
"output_file": output_blast,
"elapsed_sec": elapsed
}
else:
print(f" BLASTP失败,返回码: {result.returncode}")
return {
"success": False,
"command": " ".join(blast_cmd),
"returncode": result.returncode,
"stderr": result.stderr,
"output_file": None,
"elapsed_sec": elapsed
}
def read_fasta_ids_for_matching(fasta_path: Path) -> set[str]:
"""Read FASTA primary IDs used by BLAST/MCScanX."""
ids: set[str] = set()
try:
with open_text_maybe_gzip(fasta_path) as handle:
for line in handle:
if line.startswith(">"):
token = line[1:].strip().split()[0]
if token:
ids.add(token)
except Exception:
pass
return ids
def parse_gff3_attributes(attr_text: str) -> Dict[str, List[str]]:
attrs: Dict[str, List[str]] = {}
for item in attr_text.strip().split(";"):
item = item.strip()
if not item:
continue
if "=" in item:
key, value = item.split("=", 1)
elif " " in item:
key, value = item.split(" ", 1)
else:
continue
key = key.strip()
values = [v.strip() for v in value.strip().split(",") if v.strip()]
if key and values:
attrs.setdefault(key, []).extend(values)
return attrs
def gff_id_candidates(attrs: Dict[str, List[str]]) -> List[str]:
ordered_keys = [
"Name",
"transcript_id",
"protein_id",
"locus_tag",
"gene_id",
"gene",
"ID",
"Parent",
]
candidates: List[str] = []
for key in ordered_keys:
for value in attrs.get(key, []):
candidates.append(value)
for prefix in ("gene:", "transcript:", "rna:", "mRNA:", "CDS:", "protein:"):
if value.startswith(prefix):
candidates.append(value[len(prefix):])
for prefix in ("gene-", "rna-", "cds-", "mrna-", "protein-"):
if value.lower().startswith(prefix):
candidates.append(value[len(prefix):])
seen = set()
unique: List[str] = []
for candidate in candidates:
if candidate and candidate not in seen:
unique.append(candidate)
seen.add(candidate)
return unique
def choose_gff_feature_id(attrs: Dict[str, List[str]], preferred_ids: Optional[set[str]]) -> str:
candidates = gff_id_candidates(attrs)
if preferred_ids:
for candidate in candidates:
if candidate in preferred_ids:
return candidate
preferred_base = {item.split(".")[0]: item for item in preferred_ids}
for candidate in candidates:
match = preferred_base.get(candidate.split(".")[0])
if match:
return match
if candidates:
return candidates[0]
return ""
def filter_gff3(
gff3_file: Path,
output_gff: Path,
feature_type: str = "mRNA",
preferred_ids: Optional[set[str]] = None,
) -> Dict[str, Any]:
"""Filter GFF3 and use IDs compatible with the stage protein FASTA."""
print(f"过滤GFF3文件: {gff3_file} -> {output_gff}")
start_time = time.time()
try:
gene_count = 0
matched_count = 0
with open_text_maybe_gzip(gff3_file) as src, open(output_gff, "w", encoding="utf-8") as dst:
for raw in src:
if not raw.strip() or raw.startswith("#"):
continue
parts = raw.rstrip("\n").split("\t")
if len(parts) < 9 or parts[2] != feature_type:
continue
attrs = parse_gff3_attributes(parts[8])
feature_id = choose_gff_feature_id(attrs, preferred_ids)
if not feature_id:
continue
if preferred_ids and feature_id in preferred_ids:
matched_count += 1
dst.write(f"{parts[0]}\t{feature_id}\t{parts[3]}\t{parts[4]}\n")
gene_count += 1
elapsed = time.time() - start_time
print(f" 成功过滤 {gene_count} 个基因,匹配FASTA ID {matched_count} 个,耗时: {elapsed:.1f}秒")
return {
"success": True,
"output_file": output_gff,
"gene_count": gene_count,
"matched_fasta_id_count": matched_count,
"elapsed_sec": elapsed,
}
except Exception as e:
return {
"success": False,
"stderr": str(e),
}
def run_duplicate_classifier(
work_dir: Path,
prefix: str,
blast_file: Path,
gff_file: Path
) -> Dict[str, Any]:
"""运行duplicate_gene_classifier进行复制基因分类"""
print(f"运行Duplicate Gene Classifier...")
start_time = time.time()
try:
result = subprocess.run(
f"duplicate_gene_classifier {prefix}",
capture_output=True,
text=True,
cwd=str(work_dir),
check=False,
shell=True
)
elapsed = time.time() - start_time
# 输出文件
log_file = work_dir / f"{prefix}.dup_class.log"
gene_type_file = work_dir / "gene_type"
# 写入日志
with open(log_file, 'w') as f:
f.write(result.stdout)
if result.stderr:
f.write("\n=== STDERR ===\n")
print(f" 完成,耗时: {elapsed:.1f}秒")
print(f" 日志文件: {log_file}")
return {
"success": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"log_file": log_file,
"gene_type_file": gene_type_file,
"elapsed_sec": elapsed
}
except Exception as e:
return {
"success": False,
"stderr": str(e)
}
def run_mcscanx(
work_dir: Path,
prefix: str
) -> Dict[str, Any]:
"""运行MCScanX进行共线性分析"""
print(f"运行MCScanX...")
# 命令格式: MCScanX ./
# 需要在包含 {prefix}.gff 和 {prefix}.blast 的目录中运行
cmd = [
"MCScanX",
prefix
]
start_time = time.time()
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(work_dir),
check=False
)
elapsed = time.time() - start_time
# MCScanX 可能输出到 .collinearity 或 {prefix}.collinearity
# 检查两种可能性
collinearity_file = work_dir / f"{prefix}.collinearity"
if not collinearity_file.exists():
collinearity_file = work_dir / ".collinearity"
print(f" 完成,耗时: {elapsed:.1f}秒")
print(f" 共线性文件: {collinearity_file}")
return {
"success": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"collinearity_file": collinearity_file,
"elapsed_sec": elapsed
}
except Exception as e:
return {
"success": False,
"stderr": str(e)
}
def find_scope_gene_id_file(workspace: Path, prefix: str, scope_data: Dict[str, Any]) -> Optional[Path]:
"""Find family member IDs for scoped target-species synteny highlighting."""
if not scope_data:
return None
final_dir = workspace / "final"
family = str(scope_data.get("family") or "").strip()
targets = [str(x).strip() for x in (scope_data.get("target_species") or []) if str(x).strip()]
candidates: List[Path] = []
if targets and family:
for species in targets:
candidates.extend([
final_dir / f"Target.{species}.{family}.final_members.id",
final_dir / f"{species}.{family}.final_members.id",
final_dir / f"{species}.{prefix.split('.')[-1]}.final_members.id",
])
candidates.append(final_dir / f"{prefix}.final_members.id")
for candidate in candidates:
if candidate.exists() and candidate.stat().st_size > 0:
return candidate
return None
def _read_id_set(path: Optional[Path]) -> set[str]:
ids: set[str] = set()
if not path or not path.exists():
return ids
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw in handle:
token = raw.strip().split()[0] if raw.strip() else ""
if token:
ids.add(token)
return ids
def _parse_mcscanx_gff(gff_file: Path) -> tuple[Dict[str, tuple[str, int, int]], Dict[str, int]]:
genes: Dict[str, tuple[str, int, int]] = {}
chrom_lengths: Dict[str, int] = {}
with gff_file.open("r", encoding="utf-8", errors="replace") as handle:
for raw in handle:
if not raw.strip() or raw.startswith("#"):
continue
parts = raw.rstrip("\n").split("\t")
if len(parts) < 4:
continue
chrom, gene_id = parts[0], parts[1]
try:
start, end = int(float(parts[2])), int(float(parts[3]))
except ValueError:
continue
if start > end:
start, end = end, start
genes[gene_id] = (chrom, start, end)
chrom_lengths[chrom] = max(chrom_lengths.get(chrom, 0), end)
return genes, chrom_lengths
def _parse_collinearity_pairs(collinearity_file: Path) -> list[tuple[str, str]]:
pairs: list[tuple[str, str]] = []
with collinearity_file.open("r", encoding="utf-8", errors="replace") as handle:
for raw in handle:
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) >= 3:
pairs.append((parts[1], parts[2]))
return pairs
def generate_synteny_fallback_plot(
gff_file: Path,
collinearity_file: Path,
output_prefix: Path,
gene_id_file: Optional[Path] = None,
max_background_links: int = 3000,
) -> Dict[str, Any]:
"""Generate a chromosome-link synteny plot when Circos is unavailable.
This is a fallback visualization from the same MCScanX GFF/collinearity
outputs: chromosome models from gene coordinates, grey collinear gene-pair
links, and red links/ticks for pairs touching target family members.
"""
genes, chrom_lengths = _parse_mcscanx_gff(gff_file)
pairs = [(a, b) for a, b in _parse_collinearity_pairs(collinearity_file) if a in genes and b in genes]
family_ids = _read_id_set(gene_id_file)
if not genes or not chrom_lengths or not pairs:
return {
"success": False,
"reason": "fallback_plot_missing_gff_or_collinearity_pairs",
"genes": len(genes),
"chromosomes": len(chrom_lengths),
"pairs": len(pairs),
}
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.path import Path as MplPath
from matplotlib.patches import PathPatch
def chrom_key(name: str):
m = re.search(r"(\d+)$", name)
return (0, int(m.group(1))) if m else (1, name)
chroms = sorted(chrom_lengths, key=chrom_key)
n_chrom = len(chroms)
max_len = max(chrom_lengths.values()) or 1
fig_h = max(6.0, min(18.0, n_chrom * 0.45 + 2.2))
fig, ax = plt.subplots(figsize=(11.5, fig_h), dpi=220)
y_for = {chrom: n_chrom - i for i, chrom in enumerate(chroms)}
for chrom in chroms:
y = y_for[chrom]
length = chrom_lengths[chrom]
ax.plot([0, length], [y, y], color="#263238", linewidth=4, solid_capstyle="round", zorder=3)
ax.text(-max_len * 0.015, y, chrom, ha="right", va="center", fontsize=8, color="#263238")
highlighted = []
background = []
for gene_a, gene_b in pairs:
is_family = gene_a in family_ids or gene_b in family_ids
(highlighted if is_family else background).append((gene_a, gene_b))
if len(background) > max_background_links:
step = max(1, len(background) // max_background_links)
background = background[::step][:max_background_links]
def draw_pair(gene_a: str, gene_b: str, color: str, alpha: float, lw: float, z: int):
chr_a, start_a, end_a = genes[gene_a]
chr_b, start_b, end_b = genes[gene_b]
x1 = (start_a + end_a) / 2
x2 = (start_b + end_b) / 2
y1 = y_for[chr_a]
y2 = y_for[chr_b]
if chr_a == chr_b:
lift = 0.18 + min(0.75, abs(x2 - x1) / max_len * 2.0)
verts = [(x1, y1), ((x1 + x2) / 2, y1 + lift), (x2, y2)]
else:
lift = 0.22
verts = [(x1, y1), (x1, (y1 + y2) / 2 + lift), (x2, y2)]
patch = PathPatch(
MplPath(verts, [MplPath.MOVETO, MplPath.CURVE3, MplPath.CURVE3]),
facecolor="none",
edgecolor=color,
linewidth=lw,
alpha=alpha,
zorder=z,
)
ax.add_patch(patch)
for gene_a, gene_b in background:
draw_pair(gene_a, gene_b, "#9E9E9E", 0.18, 0.35, 1)
for gene_a, gene_b in highlighted:
draw_pair(gene_a, gene_b, "#D32F2F", 0.75, 0.85, 2)
plotted_family = 0
for gene_id in sorted(family_ids):
if gene_id not in genes:
continue
chrom, start, end = genes[gene_id]
y = y_for[chrom]
x = (start + end) / 2
ax.scatter([x], [y], s=10, color="#B71C1C", zorder=4)
plotted_family += 1
if plotted_family <= 80:
ax.text(x, y + 0.10, gene_id, rotation=60, ha="left", va="bottom", fontsize=4.5, color="#B71C1C")
ax.set_xlim(-max_len * 0.08, max_len * 1.03)
ax.set_ylim(0.4, n_chrom + 0.9)
ax.set_xlabel("Genomic position (bp)", fontsize=9)
ax.set_yticks([])
title = "Intraspecies synteny (MCScanX chromosome-link fallback)"
subtitle = f"{len(pairs)} collinear gene pairs"
if family_ids:
subtitle += f"; {len(highlighted)} pairs involving target-family genes"
ax.set_title(f"{title}\n{subtitle}", fontsize=11, pad=12)
ax.spines[["left", "right", "top"]].set_visible(False)
ax.grid(axis="x", color="#EEEEEE", linewidth=0.6)
fig.tight_layout()
png_file = output_prefix.with_suffix(".synteny.png")
pdf_file = output_prefix.with_suffix(".synteny.pdf")
fig.savefig(png_file, bbox_inches="tight")
fig.savefig(pdf_file, bbox_inches="tight")
plt.close(fig)
return {
"success": png_file.exists() and pdf_file.exists(),
"png_file": png_file,
"pdf_file": pdf_file,
"total_pairs_plotted_source": len(pairs),
"background_pairs_plotted": len(background),
"highlighted_pairs_plotted": len(highlighted),
"family_genes_plotted": plotted_family,
"fallback_reason": "circos_output_unavailable",
}
def _is_primary_chromosome_name(chrom: str) -> bool:
name = str(chrom or "").strip()
if re.fullmatch(r"\d+", name):
return True
return bool(re.fullmatch(r"chr(?:omosome)?[\s_.-]*\d+[A-Za-z]?", name, flags=re.IGNORECASE))
def _is_obvious_non_nuclear_sequence(chrom: str) -> bool:
name = str(chrom or "").strip().lower()
if re.search(r"(chloroplast|plastid|mitochond|mitoch|scaffold|contig|unplaced|unlocalized|random)", name):
return True
return bool(re.fullmatch(r"(chrm|chrmt|mt|mito|chrc|chrpt|pt|cp)", name))
def _select_circos_chromosomes(parsed: list[tuple[str, str]]) -> tuple[list[tuple[str, str]], Dict[str, Any]]:
"""Keep the nuclear chromosome background while dropping organelles/tiny accessions.
The fallback to all parsed records is intentional: species with unusual naming
should not fail synteny visualization just because a conservative filter cannot
identify the main chromosome set.
"""
records: list[tuple[str, int]] = []
for chrom, length_text in parsed:
try:
length = int(float(length_text))
except (TypeError, ValueError):
continue
if chrom and length > 0:
records.append((chrom, length))
total = len(parsed)
if not records:
return parsed, {
"circos_chromosomes_total": total,
"circos_chromosomes_plotted": len(parsed),
"circos_chromosomes_filtered": 0,
"circos_chromosomes_filter_mode": "fallback_unparsed_lengths",
"circos_chromosomes_filtered_names": [],
}
selected: list[tuple[str, int]] = []
mode = ""
named_primary = [(chrom, length) for chrom, length in records if _is_primary_chromosome_name(chrom)]
if named_primary:
selected = named_primary
mode = "primary_name"
else:
nuclear_candidates = [
(chrom, length)
for chrom, length in records
if not _is_obvious_non_nuclear_sequence(chrom)
]
length_source = nuclear_candidates or records
sorted_lengths = sorted((length for _, length in length_source), reverse=True)
median_length = sorted_lengths[len(sorted_lengths) // 2]
max_length = sorted_lengths[0]
threshold = max(1_000_000, int(median_length * 0.10), int(max_length * 0.02))
selected = [(chrom, length) for chrom, length in length_source if length >= threshold]
mode = "length_distribution"
if not selected:
selected = records
mode = "fallback_all_records"
selected_names = {chrom for chrom, _ in selected}
filtered_names = [chrom for chrom, _ in records if chrom not in selected_names]
selected_text = [(chrom, str(length)) for chrom, length in selected]
return selected_text, {
"circos_chromosomes_total": total,
"circos_chromosomes_plotted": len(selected_text),
"circos_chromosomes_filtered": max(0, total - len(selected_text)),
"circos_chromosomes_filter_mode": mode,
"circos_chromosomes_filtered_names": filtered_names,
}
def _format_circos_points(value: Any, default: float) -> str:
if value is None or value == "":
value = default
text = str(value).strip()
if text.endswith("p"):
return text
try:
return f"{float(text):g}p"
except ValueError:
return f"{default:g}p"
def _format_circos_radius(value: Any, default: float) -> str:
if value is None or value == "":
value = default
text = str(value).strip()
if "r" in text or "p" in text:
return text
try:
return f"{float(text):.2f}r"
except ValueError:
return f"{default:.2f}r"
def run_circos_prepare(
gff_file: Path,
collinearity_file: Path,
gene_id_file: Path,
output_prefix: Path,
genome_file: Path,
gene_label_size: Any = 38,
gene_track_r0: Any = 1.03,
gene_track_r1: Any = 1.30,
chrom_label_radius: Any = "1r - 60p",
chrom_label_size: Any = 40,
ideogram_thickness: Any = 90,
) -> Dict[str, Any]:
"""准备Circos绘图数据"""
print(f"准备Circos绘图数据...")
gene_label_size_text = _format_circos_points(gene_label_size, 38)
gene_track_r0_text = _format_circos_radius(gene_track_r0, 1.03)
gene_track_r1_text = _format_circos_radius(gene_track_r1, 1.30)
chrom_label_radius_text = _format_circos_radius(chrom_label_radius, 1.0)
chrom_label_size_text = _format_circos_points(chrom_label_size, 40)
ideogram_thickness_text = _format_circos_points(ideogram_thickness, 90)
# 1. 计算染色体长度
genome_len_file = output_prefix.parent / "genome.len"
cmd = [
"seqkit", "fx2tab", "-l", "-n", "-i", str(genome_file)
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
return {"success": False, "stderr": result.stderr}
lines = result.stdout.strip().split('\n')
parsed = []
for line in lines:
parts = line.split('\t')
if len(parts) >= 2:
parsed.append((parts[0], parts[1]))
chromosomes, chromosome_metrics = _select_circos_chromosomes(parsed)
with open(genome_len_file, 'w') as f:
for chrom, length in chromosomes:
f.write(f"{chrom}\t{length}\n")
# 2. 生成核型文件
karyotype_file = output_prefix.with_suffix('.karyotype.circos.txt')
cmd2 = [
"awk",
'{print "chr\t-\t"$1"\t"$1"\t0\t"$2"\tchr"NR}',
str(genome_len_file)
]
result2 = subprocess.run(
cmd2,
capture_output=True,
text=True,
check=False
)
if result2.returncode == 0:
with open(karyotype_file, 'w') as f:
f.write(result2.stdout)
# 3. 使用get_mcscanx_circos.py生成绘图数据
circos_script = Path("scripts/get_mcscanx_circos.py")
if circos_script.exists() and gene_id_file.exists():
cmd3 = [
sys.executable,
str(circos_script),
str(gff_file),
str(collinearity_file),
str(gene_id_file),
str(output_prefix)
]
result3 = subprocess.run(
cmd3,
capture_output=True,
text=True,
check=False
)
print(f" Circos数据准备完成")
# 4. 创建Circos配置文件并绘图
circos_dir = output_prefix.parent / "circos_config"
circos_dir.mkdir(exist_ok=True)
# 创建简化版circos.conf配置文件
circos_conf = circos_dir / "circos.conf"
karyotype_rel = karyotype_file.name
links_rel = f"{output_prefix.name}.links.txt"
gene_text_rel = f"{output_prefix.name}.geneText.txt"
gene_block_links_rel = f"{output_prefix.name}.geneBlocklinks.txt"
# 检查SPL基因相关文件是否存在
gene_text_file = Path(f"{output_prefix}.geneText.txt")
gene_block_links_file = Path(f"{output_prefix}.geneBlocklinks.txt")
has_spl_data = gene_text_file.exists() and gene_block_links_file.exists()
with open(circos_conf, 'w') as f:
f.write(f"""# Circos configuration file
# Generated by BotanicalBaseAgent Synteny pipeline
karyotype = {karyotype_rel}
chromosomes_units = 1000000
chromosomes_display_default = yes
<<include /home/xzq/miniconda3/envs/test4py311/etc/colors_fonts_patterns.conf>>
<ideogram>
<spacing>
default = 0.01r
</spacing>
radius = 0.5r
thickness = {ideogram_thickness_text}
fill = yes
fill_color = black
stroke_color = dgrey
stroke_thickness = 1p
show_label = yes
label_font = default
label_radius = {chrom_label_radius_text}
label_size = {chrom_label_size_text}
label_parallel = yes
</ideogram>
<ticks>
radius = 1r
color = black
thickness = 2p
multiplier = 1e-6
orientation = out
format = %d
<tick>
spacing = 10u
size = 15p
show_label = no
format = %d
</tick>
</ticks>
""")
# 添加plots区块显示基因ID
if has_spl_data:
f.write(f"""
<plots>
<plot>
type = text
file = {gene_text_rel}
color = red
r1 = {gene_track_r1_text}
r0 = {gene_track_r0_text}
label_font = default
label_size = {gene_label_size_text}
label_snuggle = yes
show_links = yes
link_dims = 5p,4p,60p,2p,2p
link_thickness = 2p
link_color = green
</plot>
</plots>
""")
# 添加links区块
f.write(f"""
<links>
<link>
file = {links_rel}
radius = 0.9r
bezier_radius = 0r
color = lgrey
thickness = 2
ribbon = yes
</link>
""")
# 添加SPL基因高亮连线
if has_spl_data:
f.write(f"""<link>
file = {gene_block_links_rel}
radius = 0.9r
bezier_radius = 0r
color = red
thickness = 2
ribbon = yes
</link>
""")
f.write(f"""</links>
<image>
dir = .
file = {output_prefix.name}.circos
png = yes
svg = yes
radius = 3000p
background = white
angle_offset = -90
</image>
<<include /home/xzq/miniconda3/envs/test4py311/etc/housekeeping.conf>>
""")
# 复制Circos输入文件到circos_config目录。Circos以cwd运行,
# 配置中使用相对文件名,必须覆盖旧的空文件并同步links/text输入。
import shutil
for src in (
karyotype_file,
Path(f"{output_prefix}.links.txt"),
Path(f"{output_prefix}.geneText.txt"),
Path(f"{output_prefix}.geneBlocklinks.txt"),
):
if src.exists():
shutil.copy2(src, circos_dir / src.name)
# 运行Circos绘图
print(f" 运行Circos绘图...")
cmd4 = [
"circos",
"-conf", str(circos_conf.resolve())
]
circos_env = os.environ.copy()
conda_bin = "/home/xzq/miniconda3/envs/test4py311/bin"
circos_env["PATH"] = conda_bin + ":" + circos_env.get("PATH", "")
circos_env.pop("PERL5LIB", None)
result4 = subprocess.run(
cmd4,
capture_output=True,
text=True,
check=False,
cwd=str(circos_dir), # 在circos_config目录中运行
env=circos_env
)
# 检查输出文件(在circos_config目录中)
svg_file_in_config = circos_dir / f"{output_prefix.name}.circos.svg"
png_file_in_config = circos_dir / f"{output_prefix.name}.circos.png"
# 输出文件目标位置(analysis目录)
svg_file = output_prefix.parent / f"{output_prefix.name}.circos.svg"
png_file = output_prefix.parent / f"{output_prefix.name}.circos.png"
# 移动输出文件到analysis目录
if svg_file_in_config.exists():
shutil.move(svg_file_in_config, svg_file)
if png_file_in_config.exists():
shutil.move(png_file_in_config, png_file)
# 如果只有SVG没有PNG,尝试转换
if svg_file.exists() and not png_file.exists():
print(f" 转换SVG为PNG...")
convert_cmd = [
"convert",
"-density", "300",
str(svg_file),
str(png_file)
]
subprocess.run(convert_cmd, capture_output=True, check=False)
circos_success = result4.returncode == 0
print(f" Circos绘图完成" if circos_success else f" Circos绘图失败: {result4.stderr[:200]}")
return {
"success": circos_success,
"circos_returncode": result4.returncode,
"circos_stdout": result4.stdout,
"circos_stderr": result4.stderr,
"genome_len_file": genome_len_file,
"karyotype_file": karyotype_file,
"links_file": f"{output_prefix}.links.txt",
"genepair_file": f"{output_prefix}.genepair.txt",
"circos_conf": circos_conf,
"svg_file": svg_file if svg_file.exists() else None,
"png_file": png_file if png_file.exists() else None,
**chromosome_metrics,
}
return {
"success": True,
"genome_len_file": genome_len_file,
"karyotype_file": karyotype_file,
**chromosome_metrics,
}
def _create_circos_config_files(config_dir: Path):
"""创建Circos所需的配置文件"""
# ideogram.conf - 使用内联配置,避免嵌套include
with open(config_dir / "ideogram.conf", 'w') as f:
f.write("""<ideogram>
<spacing>
default = 0.01r
break = 0.5r
</spacing>
radius = 0.90r
thickness = 100p
fill = yes
fill_color = black
show_label = yes
label_font = default
label_radius = 1.075r
label_size = 10p
label_parallel = yes
show_bands = yes
fill_bands = yes
thickness = 2p
</ideogram>
""")
# ticks.conf
with open(config_dir / "ticks.conf", 'w') as f:
f.write("""show_ticks = yes
show_tick_labels = yes
<ticks>
radius = 1r
color = black
thickness = 2p
<tick>
spacing = 5u
size = 10p
show_label = no
</tick>
<tick>
spacing = 25u
size = 15p
show_label = yes
label_size = 10p
label_offset = 5p
format = %d
</tick>
</ticks>
""")
# image.conf
with open(config_dir / "image.conf", 'w') as f:
f.write("""dir = .
file = circos
png = yes
svg = yes
radius = 1500p
background = white
angle_offset = -90
""")
# colors_fonts_patterns.conf - 使用系统默认配置
with open(config_dir / "colors_fonts_patterns.conf", 'w') as f:
f.write("""# 使用Circos系统默认颜色配置
# 这些文件在Circos安装目录的etc/下
<<include /home/xzq/miniconda3/envs/test4py311/etc/colors.ucsc.conf>>
<<include /home/xzq/miniconda3/envs/test4py311/etc/colors.brewer.conf>>
<<include /home/xzq/miniconda3/envs/test4py311/etc/colors.hsv.conf>>
<<include /home/xzq/miniconda3/envs/test4py311/etc/fonts.conf>>
<<include /home/xzq/miniconda3/envs/test4py311/etc/patterns.conf>>
""")
# housekeeping.conf
with open(config_dir / "housekeeping.conf", 'w') as f:
f.write("""anglestep = 0.5
minslicestep = 10
beziersamples = 40
warnings = 1
debug_group = summary
debug_verbosity = 5
text_snuggle_method = max
snuggle_tolerance = 1
snuggle_sampling = 2
snuggle_refine = yes
snuggle_link_overlap_tolerance = 1
offsets = 0
file_delim = \\s
file_delim_collapse = yes
list_record_delim = \\s*[;,]\\s*
list_field_delim = \\s*[:=]\\s*
options_record_delim = \\s*[;,]\\s*
options_field_delim = \\s*[:=]\\s*
""")
def compute_blast_stats(blast_file: Path) -> Dict[str, Any]:
"""计算BLAST比对统计"""
try:
with open(blast_file, 'r') as f:
lines = f.readlines()
# 统计比对数量
return {
"total_alignments": len(lines),
"unique_queries": len(set(l.split('\t')[0] for l in lines if l.strip())),
"unique_subjects": len(set(l.split('\t')[1] for l in lines if l.strip()))
}
except Exception:
return {}
def compute_collinearity_stats(collinearity_file: Path) -> Dict[str, Any]:
"""计算共线性统计"""
try:
with open(collinearity_file, 'r') as f:
content = f.read()
# 统计共线性块数量
blocks = content.count('##')
# 统计基因对数量
gene_pairs = 0
for line in content.split('\n'):
if line.startswith('#') or not line.strip():
continue
parts = line.split('\t')
if len(parts) >= 3:
gene_pairs += 1
return {
"synteny_blocks": blocks,
"gene_pairs": gene_pairs
}
except Exception:
return {}
def write_report(
workspace: Path,
prefix: str,
metrics: Dict[str, Any],
parameters: Dict[str, Any],
tool_versions: Dict[str, str],
diamond_result: Dict[str, Any],
gff_filter_result: Dict[str, Any],
classifier_result: Dict[str, Any],
mcscanx_result: Dict[str, Any],
artifacts: Dict[str, Dict]
) -> Path:
"""写入人可读的分析报告"""
report_path = workspace / "final" / f"{prefix}.synteny_report.md"
print(f"生成分析报告: {report_path}")
with open(report_path, "w") as f:
f.write(f"# 共线性分析报告 - {prefix}\n\n")
f.write(f"**生成时间**: {datetime.now().isoformat()}\n\n")
f.write("## 1. 分析概述\n")
f.write(f"- **分析阶段**: Synteny\n")
f.write(f"- **分析状态**: {'成功' if mcscanx_result.get('success', False) else '部分成功'}\n")
f.write(f"- **基因数量**: {metrics.get('gene_count', 'N/A')}\n")
f.write(f"- **BLAST比对数**: {metrics.get('blast_alignments', 'N/A')}\n")
f.write(f"- **共线性块数**: {metrics.get('synteny_blocks', '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("### BLAST比对\n")
if diamond_result.get('command'):
f.write(f"```bash\n{diamond_result.get('command')}\n```\n\n")
f.write("### GFF过滤\n")
f.write(f"```bash\nawk -F '\\t' ...\n```\n\n")
f.write("### 复制基因分类\n")
if classifier_result.get('returncode') == 0:
f.write("✅ **复制基因分类成功**\n\n")
f.write("### MCScanX共线性分析\n")
if mcscanx_result.get('success'):
f.write("✅ **共线性分析成功**\n\n")
f.write("### Circos共线性绘图\n")
if artifacts.get('circos_svg') or artifacts.get('circos_png'):
f.write("✅ **Circos共线性绘图成功**\n\n")
f.write("Circos图展示了基因在染色体间的共线性关系,包括:\n")
f.write("- **染色体圈图**: 外圈显示染色体及其长度\n")
f.write("- **共线性连线**: 弧线连接具有共线性关系的基因对\n")
f.write("- **基因标签**: 显示关键基因的名称和位置\n")
if metrics.get('circos_chromosomes_total') is not None:
f.write(
"- **染色体筛选**: 圈图保留主核染色体背景,"
"过滤掉疑似细胞器/非主序列 "
f"({metrics.get('circos_chromosomes_plotted', 'N/A')}/"
f"{metrics.get('circos_chromosomes_total', 'N/A')} plotted)\n"
)
f.write("\n")
else:
f.write("⚠️ **Circos绘图未生成**\n\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")
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],
status: str = "success"
) -> Path:
"""写入符合schema v1的manifest文件"""
elapsed_sec = (end_time - start_time).total_seconds()
rebased_parameters = rebase_manifest_payload_paths(workspace, parameters)
rebased_artifacts = rebase_manifest_payload_paths(workspace, artifacts)
rebased_metrics = rebase_manifest_payload_paths(workspace, metrics)
manifest_data = {
"manifest_version": "1.0",
"job_id": job_id,
"stage": "synteny",
"run_id": run_id,
"prefix": prefix,
"timestamp": end_time.isoformat(),
"status": status,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"elapsed_sec": round(elapsed_sec, 2),
"cmd": rebase_manifest_payload_paths(workspace, cmd_history),
"tool_versions": tool_versions,
"workspace_root": str(workspace.parent.absolute()),
"workspace": str((workspace / "final").absolute()),
"input_dir": str(current_input_dir(workspace).absolute()),
"inputs": [
{"type": "protein_fasta", "path": rebased_parameters.get("protein_fasta", "")},
{"type": "gff3", "path": rebased_parameters.get("gff3", "")},
{"type": "genome", "path": rebased_parameters.get("genome", "")}
],
"artifacts": rebased_artifacts,
"parameters": rebased_parameters,
"metrics": rebased_metrics,
"provenance": {
"derived_from": f"{prefix}.identify.manifest.json",
"stage_sequence": ["identify", "phylogeny", "synteny"]
}
}
for key in (
"scope_id",
"scope_mode",
"scope_file",
"analysis_route",
"member_set_id",
"target_species",
"reference_species",
"comparative_species",
"selected_species",
"family",
):
if key in rebased_parameters:
manifest_data[key] = rebased_parameters[key]
manifest_path = workspace / "final" / f"{prefix}.synteny.manifest.json"
manifest_path.parent.mkdir(parents=True, exist_ok=True)
print(f"生成Manifest文件: {manifest_path}")
with open(manifest_path, "w", encoding='utf-8') as f:
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
if parameters.get("scope_id"):
scoped_manifest_path = workspace / "final" / f"{prefix}.{sanitize_token(parameters.get('scope_id', ''))}.synteny.manifest.json"
scoped_manifest_data = dict(manifest_data)
scoped_manifest_data["artifacts"] = materialize_scoped_artifacts(
artifacts=rebased_artifacts,
workspace=workspace,
prefix=prefix,
scope_id=parameters.get("scope_id", ""),
)
with open(scoped_manifest_path, "w", encoding='utf-8') as f:
json.dump(scoped_manifest_data, f, indent=2, ensure_ascii=False)
return manifest_path
def main():
"""主函数:执行Synteny分析流程"""
parser = argparse.ArgumentParser(
description="Synteny阶段:种内共线性分析",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python run_synteny.py --prefix Ft.SPL --workspace /path/to/workspace --cpu 4
输入要求:
- Identify阶段必须已成功运行
- 需要GFF3注释文件和基因组文件
输出产物:
- final/{prefix}.blast (BLAST比对结果)
- final/{prefix}.gff (过滤后的GFF)
- final/{prefix}.collinearity (MCScanX共线性结果)
- final/{prefix}.synteny_report.md (分析报告)
- final/{prefix}.synteny.manifest.json (机器清单)
"""
)
parser.add_argument("--prefix", required=True,
help="分析前缀,格式:物种.家族(如 Ft.SPL)")
parser.add_argument("--workspace", required=True,
help="工作空间目录(workspace/<job_id>/)")
parser.add_argument("--input-dir",
help="输入目录(默认:workspace/<job_id>/../input/<job_id>)")
parser.add_argument("--cpu", type=int, default=8,
help="CPU核心数(默认:8)")
parser.add_argument("--gff3",
help="GFF3注释文件路径")
parser.add_argument("--genome",
help="基因组FASTA文件路径")
parser.add_argument("--evalue", type=float, default=1e-10,
help="BLAST E-value阈值(默认:1e-10)")
parser.add_argument("--max-target-seqs", type=int, default=5,
help="最大目标序列数(默认:5)")
parser.add_argument("--use-blastp", action="store_true",
help="使用NCBI blastp代替diamond")
parser.add_argument("--log-file",
help="日志文件路径")
parser.add_argument("--gene-id-file",
help="基因ID列表文件路径(可选,用于Circos绘图)")
parser.add_argument("--scope-file",
help="Gene-family scope JSON from resolve_scope.py")
parser.add_argument("--analysis-route",
help="Scoped analysis route recorded in manifest, e.g. single_species")
parser.add_argument("--member-set-id",
help="Scoped member set identity recorded in manifest")
args = parser.parse_args()
# 记录开始时间
start_time = datetime.now()
run_id = f"synteny_{start_time.strftime('%Y%m%d_%H%M%S_%f')[:-3]}"
# 设置工作空间
workspace = Path(args.workspace)
job_id = workspace.name
# 设置输入目录
if args.input_dir:
input_dir = Path(args.input_dir)
else:
project_root = workspace.parent.parent
input_dir = project_root / "input" / job_id
scope_data: Dict[str, Any] = {}
scope_protein: Optional[Path] = None
scope_gff3: Optional[Path] = None
scope_genome: Optional[Path] = None
analysis_route = str(args.analysis_route or "").strip()
member_set_id = str(args.member_set_id or "").strip()
print("=" * 60)
print(f"🚀 启动共线性分析")
print("=" * 60)
print(f"分析前缀: {args.prefix}")
print(f"工作空间: {workspace}")
print(f"任务ID: {job_id}")
print(f"运行ID: {run_id}")
print(f"CPU核心: {args.cpu}")
cmd_history = []
try:
scope_file: Optional[Path] = None
if args.scope_file:
scope_file = Path(args.scope_file)
if not scope_file.exists():
raise FileNotFoundError(f"Scope file not found: {args.scope_file}")
scope_data = read_scope(scope_file)
scope_data, scope_protein, scope_gff3, scope_genome = resolve_synteny_scope_inputs(workspace, scope_file)
scope_targets = [str(x).strip() for x in (scope_data.get("target_species") or []) if str(x).strip()]
scope_target_species = scope_targets[0] if scope_targets else ""
scope_protein, scope_protein_source = prefer_representative_target_file(
input_dir, scope_target_species, "pep", scope_protein
)
scope_gff3, scope_gff_source = prefer_representative_target_file(
input_dir, scope_target_species, "gff", scope_gff3
)
scope_genome, scope_genome_source = prefer_representative_target_file(
input_dir, scope_target_species, "genome", scope_genome
)
if scope_protein_source == "representative":
print(f" Scope代表转录本PEP: {scope_protein}")
elif scope_protein_source == "scope_raw_fallback":
print(f" ⚠️ 未找到 {scope_target_species} 代表转录本PEP,回退使用scope原始PEP: {scope_protein}")
if scope_gff_source == "representative":
print(f" Scope代表转录本GFF: {scope_gff3}")
elif scope_gff_source == "scope_raw_fallback":
print(f" ⚠️ 未找到 {scope_target_species} 代表转录本GFF,回退使用scope原始GFF: {scope_gff3}")
if scope_genome_source == "representative":
print(f" Scope代表基因组FASTA: {scope_genome}")
elif scope_genome_source == "scope_raw_fallback":
print(f" ⚠️ 未找到 {scope_target_species} 代表基因组FASTA,回退使用scope原始genome: {scope_genome}")
if not analysis_route:
analysis_route = "single_species"
if not member_set_id and scope_data.get("scope_id") and analysis_route:
member_set_id = f"{scope_data.get('scope_id')}:{analysis_route}:all_members"
# 1. 准备目录结构
print("\n📁 准备目录结构...")
paths = prepare_directories(workspace)
# 2. 获取工具版本
print("\n🔧 检查工具版本...")
tool_versions = get_tool_versions()
for tool, version in tool_versions.items():
print(f" {tool}: {version}")
# 3. 验证输入文件
print("\n🔍 验证输入文件...")
inputs = validate_inputs(
workspace=workspace,
prefix=args.prefix,
input_dir=input_dir,
gff3=args.gff3 or scope_gff3,
genome=args.genome or scope_genome,
protein_fasta=scope_protein,
)
if not inputs.get("gene_id_file") and scope_data:
scoped_gene_id_file = find_scope_gene_id_file(workspace, args.prefix, scope_data)
if scoped_gene_id_file:
inputs["gene_id_file"] = scoped_gene_id_file
print(f" Scope基因ID文件: {scoped_gene_id_file}")
original_protein_fasta = inputs["protein_fasta"]
safe_protein_fasta, safe_stats = write_blast_safe_protein_fasta(
Path(original_protein_fasta),
paths["analysis_dir"] / f"{args.prefix}.blast_safe.pep.fa",
)
inputs["protein_fasta"] = safe_protein_fasta
print(
" BLAST-safe protein FASTA: "
f"{safe_protein_fasta} "
f"(records={safe_stats['records']}, removed_chars={safe_stats['removed_chars']})"
)
# 4. 运行BLAST比对
print("\n🔄 运行BLAST比对...")
blast_file = paths["analysis_dir"] / f"{args.prefix}.blast"
if args.use_blastp:
blast_result = run_blastp(
input_fasta=inputs["protein_fasta"],
output_blast=blast_file,
cpu=args.cpu,
evalue=args.evalue,
max_alignments=args.max_target_seqs
)
else:
# 优先尝试diamond
blast_result = run_diamond_blastp(
input_fasta=inputs["protein_fasta"],
output_blast=blast_file,
cpu=args.cpu,
evalue=args.evalue,
max_target_seqs=args.max_target_seqs
)
# 如果diamond失败,尝试blastp
if not blast_result.get("success", False):
print(" Diamond失败,尝试使用NCBI blastp...")
blast_result = run_blastp(
input_fasta=inputs["protein_fasta"],
output_blast=blast_file,
cpu=args.cpu,
evalue=args.evalue,
max_alignments=args.max_target_seqs
)
if not blast_result.get("success", False):
print("❌ BLAST比对失败")
raise RuntimeError(f"BLAST失败: {blast_result.get('stderr', '未知错误')}")
cmd_history.append(blast_result.get("command", ""))
# 5. 复制blast文件到工作目录
work_blast = paths["analysis_dir"] / f"{args.prefix}.blast"
import shutil
# 只有在源和目标不同时才复制
if blast_file != work_blast:
shutil.copy(blast_file, work_blast)
# 6. 过滤GFF3
print("\n🔍 过滤GFF3文件...")
gff_file = paths["analysis_dir"] / f"{args.prefix}.gff"
preferred_gff_ids = read_fasta_ids_for_matching(inputs["protein_fasta"])
gff_filter_result = filter_gff3(
gff3_file=inputs["gff3"],
output_gff=gff_file,
preferred_ids=preferred_gff_ids,
)
if not gff_filter_result.get("success", False):
print("❌ GFF3过滤失败")
raise RuntimeError(f"GFF3过滤失败")
work_gff = paths["analysis_dir"] / f"{args.prefix}.gff"
# 只有在源和目标不同时才复制
if gff_file != work_gff:
shutil.copy(gff_file, work_gff)
# 7. 运行duplicate_gene_classifier
print("\n🔍 运行复制基因分类...")
# 对于 MCScanX 和 duplicate_gene_classifier,使用物种前缀(如 Ft)而非完整前缀(如 Ft.SPL)
# 因为这些工具在处理带点的前缀时会有问题
species_prefix = args.prefix.split('.')[0] if '.' in args.prefix else args.prefix
# 复制文件并重命名为物种前缀
import shutil
work_blast_species = paths["analysis_dir"] / f"{species_prefix}.blast"
work_gff_species = paths["analysis_dir"] / f"{species_prefix}.gff"
if work_blast != work_blast_species:
shutil.copy(work_blast, work_blast_species)
if work_gff != work_gff_species:
shutil.copy(work_gff, work_gff_species)
# 使用物种前缀运行 duplicate_gene_classifier
classifier_result = run_duplicate_classifier(
work_dir=paths["analysis_dir"],
prefix=species_prefix,
blast_file=work_blast_species,
gff_file=work_gff_species
)
# 检查是否成功 - 只要有返回结果就算成功
if classifier_result and (classifier_result.get("returncode") == 0 or classifier_result.get("log_file")):
print(" 复制基因分类完成")
# 8. 运行MCScanX
print("\n🔍 运行MCScanX共线性分析...")
mcscanx_result = run_mcscanx(
work_dir=paths["analysis_dir"],
prefix=species_prefix
)
if mcscanx_result.get("success", False):
print(" 共线性分析完成")
else:
print(" ⚠️ MCScanX可能存在问题")
# 9. 准备Circos绘图数据(可选)
circos_data = {}
if mcscanx_result.get("success", False) and inputs.get("gene_id_file"):
print("\n🔍 准备Circos绘图数据...")
circos_data = run_circos_prepare(
gff_file=gff_file,
collinearity_file=mcscanx_result.get("collinearity_file", paths["analysis_dir"] / f"{args.prefix}.collinearity"),
gene_id_file=inputs["gene_id_file"],
output_prefix=paths["analysis_dir"] / f"{args.prefix}.circos",
genome_file=inputs["genome"]
)
# 10. 计算统计信息
print("\n📊 计算统计信息...")
metrics = {}
# BLAST统计
if blast_file.exists():
blast_stats = compute_blast_stats(blast_file)
metrics.update(blast_stats)
# GFF统计
metrics["gene_count"] = gff_filter_result.get("gene_count", 0)
# 共线性统计 - 使用 species_prefix
collinearity_file = paths["analysis_dir"] / f"{species_prefix}.collinearity"
if collinearity_file.exists():
collinearity_stats = compute_collinearity_stats(collinearity_file)
metrics.update(collinearity_stats)
for key in (
"circos_chromosomes_total",
"circos_chromosomes_plotted",
"circos_chromosomes_filtered",
"circos_chromosomes_filter_mode",
"circos_chromosomes_filtered_names",
):
if key in circos_data:
metrics[key] = circos_data[key]
# 10.5 Circos不可用或未产图时,使用MCScanX结果生成标准染色体-link后备图
fallback_plot = {}
circos_has_image = bool(
circos_data.get("success", False)
and (
(circos_data.get("svg_file") and Path(circos_data["svg_file"]).exists())
or (circos_data.get("png_file") and Path(circos_data["png_file"]).exists())
)
)
if collinearity_file.exists() and not circos_has_image:
print("\n🎨 Circos未产出图片,生成MCScanX染色体-link后备图...")
fallback_plot = generate_synteny_fallback_plot(
gff_file=gff_file,
collinearity_file=collinearity_file,
output_prefix=paths["analysis_dir"] / f"{args.prefix}.fallback",
gene_id_file=inputs.get("gene_id_file"),
)
if fallback_plot.get("success"):
print(
" 后备图完成: "
f"highlighted_pairs={fallback_plot.get('highlighted_pairs_plotted', 0)}, "
f"family_genes={fallback_plot.get('family_genes_plotted', 0)}"
)
else:
print(f" ⚠️ 后备图未生成: {fallback_plot.get('reason', 'unknown')}")
# 11. 准备artifacts
artifacts = {}
# blast文件
if blast_file.exists():
artifacts["blast"] = {
"artifact": "blast",
"path": f"synteny/analysis/{args.prefix}.blast",
"full_path": str(blast_file.absolute()),
"type": "blast",
"size": blast_file.stat().st_size,
"sha256": compute_file_hash(blast_file)
}
# gff文件
if gff_file.exists():
artifacts["gff"] = {
"artifact": "gff",
"path": f"synteny/analysis/{args.prefix}.gff",
"full_path": str(gff_file.absolute()),
"type": "gff",
"size": gff_file.stat().st_size,
"sha256": compute_file_hash(gff_file)
}
# 共线性文件
if collinearity_file.exists():
artifacts["collinearity"] = {
"artifact": "collinearity",
"path": f"synteny/analysis/{species_prefix}.collinearity",
"full_path": str(collinearity_file.absolute()),
"type": "text",
"size": collinearity_file.stat().st_size,
"sha256": compute_file_hash(collinearity_file)
}
# 复制基因分类日志 - 使用 species_prefix
dup_log = paths["analysis_dir"] / f"{species_prefix}.dup_class.log"
if not dup_log.exists():
# 也检查 args.prefix 版本
dup_log = paths["analysis_dir"] / f"{args.prefix}.dup_class.log"
if dup_log.exists():
artifacts["dup_class_log"] = {
"artifact": "dup_class_log",
"path": f"synteny/analysis/{args.prefix}.dup_class.log",
"full_path": str(dup_log.absolute()),
"type": "text",
"size": dup_log.stat().st_size,
"sha256": compute_file_hash(dup_log)
}
# Circos绘图文件
if circos_data.get("success", False):
# SVG文件 - 复制到final目录
if circos_data.get("svg_file") and Path(circos_data["svg_file"]).exists():
svg_file = Path(circos_data["svg_file"])
final_svg = paths["final_dir"] / f"{args.prefix}.circos.svg"
shutil.copy(svg_file, final_svg)
artifacts["circos_svg"] = {
"artifact": "circos_svg",
"path": f"final/{args.prefix}.circos.svg",
"full_path": str(final_svg.absolute()),
"type": "image",
"format": "svg",
"size": final_svg.stat().st_size,
"sha256": compute_file_hash(final_svg)
}
# PNG文件 - 复制到final目录
if circos_data.get("png_file") and Path(circos_data["png_file"]).exists():
png_file = Path(circos_data["png_file"])
final_png = paths["final_dir"] / f"{args.prefix}.circos.png"
shutil.copy(png_file, final_png)
artifacts["circos_png"] = {
"artifact": "circos_png",
"path": f"final/{args.prefix}.circos.png",
"full_path": str(final_png.absolute()),
"type": "image",
"format": "png",
"size": final_png.stat().st_size,
"sha256": compute_file_hash(final_png)
}
# Links文件
links_file = paths["analysis_dir"] / f"{args.prefix}.circos.links.txt"
if links_file.exists():
artifacts["circos_links"] = {
"artifact": "circos_links",
"path": f"synteny/analysis/{args.prefix}.circos.links.txt",
"full_path": str(links_file.absolute()),
"type": "text",
"size": links_file.stat().st_size,
"sha256": compute_file_hash(links_file)
}
if fallback_plot.get("success"):
for key, fmt in (("png_file", "png"), ("pdf_file", "pdf")):
src = Path(fallback_plot[key])
final_plot = paths["final_dir"] / f"{args.prefix}.synteny.{fmt}"
shutil.copy(src, final_plot)
artifacts[f"synteny_{fmt}"] = {
"artifact": f"synteny_{fmt}",
"path": f"final/{args.prefix}.synteny.{fmt}",
"full_path": str(final_plot.absolute()),
"type": "image" if fmt == "png" else "pdf",
"format": fmt,
"size": final_plot.stat().st_size,
"sha256": compute_file_hash(final_plot),
"source": "MCScanX fallback chromosome-link plot",
}
artifacts["synteny_plot_metadata"] = {
"artifact": "synteny_plot_metadata",
"path": "",
"type": "metadata",
"fallback_reason": fallback_plot.get("fallback_reason"),
"total_pairs_plotted_source": fallback_plot.get("total_pairs_plotted_source", 0),
"background_pairs_plotted": fallback_plot.get("background_pairs_plotted", 0),
"highlighted_pairs_plotted": fallback_plot.get("highlighted_pairs_plotted", 0),
"family_genes_plotted": fallback_plot.get("family_genes_plotted", 0),
}
# 12. 确定状态
status = "success"
if not blast_result.get("success", False):
status = "failed"
# 13. 准备参数
parameters = {
"cpu": args.cpu,
"prefix": args.prefix,
"evalue": args.evalue,
"max_target_seqs": args.max_target_seqs,
"use_blastp": args.use_blastp,
"stage": "synteny",
"job_id": job_id,
"run_id": run_id,
"protein_fasta": str(inputs["protein_fasta"]),
"gff3": str(inputs["gff3"]),
"genome": str(inputs["genome"]),
"tool_versions": tool_versions
}
if scope_data:
parameters.update({
"synteny_pep_source": locals().get("scope_protein_source", ""),
"synteny_gff_source": locals().get("scope_gff_source", ""),
"synteny_genome_source": locals().get("scope_genome_source", ""),
})
if scope_data:
parameters.update({
"scope_file": str(Path(args.scope_file).absolute()) if args.scope_file else "",
"scope_id": scope_data.get("scope_id", ""),
"scope_mode": scope_data.get("scope_mode", ""),
"analysis_route": analysis_route,
"member_set_id": member_set_id,
"target_species": scope_data.get("target_species", []),
"reference_species": scope_data.get("reference_species", []),
"comparative_species": scope_data.get("comparative_species", []),
"selected_species": scope_data.get("selected_species", []),
"family": scope_data.get("family", ""),
})
# 14. 写入报告
end_time = datetime.now()
print("\n📝 生成分析报告...")
report_path = write_report(
workspace=workspace,
prefix=args.prefix,
metrics=metrics,
parameters=parameters,
tool_versions=tool_versions,
diamond_result=blast_result,
gff_filter_result=gff_filter_result,
classifier_result=classifier_result,
mcscanx_result=mcscanx_result,
artifacts=artifacts
)
artifacts["report"] = {
"artifact": "report",
"path": f"final/{args.prefix}.synteny_report.md",
"full_path": str(report_path.absolute()),
"type": "markdown",
"size": report_path.stat().st_size,
"sha256": compute_file_hash(report_path)
}
# 15. 写入Manifest
print("\n📋 生成Manifest文件...")
manifest_path = write_manifest(
workspace=workspace,
job_id=job_id,
run_id=run_id,
prefix=args.prefix,
start_time=start_time,
end_time=end_time,
artifacts=artifacts,
metrics=metrics,
parameters=parameters,
tool_versions=tool_versions,
cmd_history=cmd_history,
status=status
)
artifacts["manifest"] = {
"artifact": "manifest",
"path": f"final/{args.prefix}.synteny.manifest.json",
"full_path": str(manifest_path.absolute()),
"type": "json",
"size": manifest_path.stat().st_size,
"sha256": compute_file_hash(manifest_path)
}
print("\n" + "=" * 60)
print(f"✅ 共线性分析完成!")
print(f"📊 基因数量: {metrics.get('gene_count', 'N/A')}")
print(f"🔗 共线性块数: {metrics.get('synteny_blocks', 'N/A')}")
print(f"📁 结果目录: {workspace}")
print("=" * 60)
return 0
except Exception as e:
print(f"\n❌ 共线性分析失败: {e}")
import traceback
traceback.print_exc()
# 写入失败manifest
end_time = datetime.now()
write_manifest(
workspace=workspace,
job_id=job_id,
run_id=run_id,
prefix=args.prefix,
start_time=start_time,
end_time=end_time,
artifacts={},
metrics={},
parameters={
"prefix": args.prefix,
"stage": "synteny",
"scope_file": str(Path(args.scope_file).absolute()) if args.scope_file else "",
"scope_id": scope_data.get("scope_id", ""),
"scope_mode": scope_data.get("scope_mode", ""),
"analysis_route": analysis_route,
"member_set_id": member_set_id,
"target_species": scope_data.get("target_species", []),
"reference_species": scope_data.get("reference_species", []),
"comparative_species": scope_data.get("comparative_species", []),
"selected_species": scope_data.get("selected_species", []),
"family": scope_data.get("family", ""),
},
tool_versions={},
cmd_history=cmd_history,
status="failed"
)
return 1
if __name__ == "__main__":
sys.exit(main())