Files

4068 lines
173 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
#!/usr/bin/env python3
"""
identify stage (web version) - 修订版
使用原始用户上传文件,而不是预处理文件
Main tasks:
- find HMM: user uploaded, or fetch from Pfam-A.hmm by required_pfams.
- hmmsearch with HMM vs protein sequences.
- domain QC and final member selection.
Inputs:
- input/<job_id>/ (包含用户上传的所有原始文件)
- 不再使用 workspace/<job_id>/prepared/ 中的预处理文件
Outputs:
- workspace/<job_id>/final/<prefix>.identify_report.md
- workspace/<job_id>/final/<prefix>.identified_members.pep.fa
- workspace/<job_id>/final/<prefix>.identified_members.id
- workspace/<job_id>/final/<prefix>.identify.manifest.json
Design:
- 直接从 input/<job_id>/ 查找原始蛋白质序列文件
- 用户上传的HMM优先使用
- 如果没有HMM,则从Pfam数据库获取
- 直接使用原始文件进行hmmsearch和blast分析
"""
import os
import gzip
import shutil
import psutil
import argparse
import json
import logging
import re
import shlex
import subprocess
import sys
import time
import hashlib
import platform
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Optional, Tuple, Dict, Any, Union, Set
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
from gfa_core.gene_family_stage_context import species_prefix_for
CONDA_PREFIX = Path("/home/xzq/miniconda3/envs/test4py311")
CONDA_BIN = str(CONDA_PREFIX / "bin")
CONDA_LIB = str(CONDA_PREFIX / "lib")
if CONDA_BIN not in os.environ.get("PATH", ""):
os.environ["PATH"] = CONDA_BIN + ":" + os.environ.get("PATH", "")
if Path(CONDA_LIB).exists() and CONDA_LIB not in os.environ.get("LD_LIBRARY_PATH", ""):
os.environ["LD_LIBRARY_PATH"] = CONDA_LIB + ":" + os.environ.get("LD_LIBRARY_PATH", "")
def copy2_if_different(source: Path, dest: Path) -> bool:
"""Copy a file unless source and destination resolve to the same path."""
source = Path(source)
dest = Path(dest)
if source.resolve() == dest.resolve():
return False
shutil.copy2(source, dest)
return True
def get_pfam_from_rag(gene_family_name: str, rag_db_path: Path = None) -> str:
"""从本地映射或RAG服务获取Pfam ID"""
if rag_db_path is None:
project_root = Path(__file__).resolve().parents[2]
candidates = [
project_root / "data" / "rag_db" / "pfam_mapping.json",
Path(__file__).resolve().parents[1] / "data" / "rag_db" / "pfam_mapping.json",
]
rag_db_path = next((p for p in candidates if p.exists()), candidates[0])
family_lower = gene_family_name.lower()
if rag_db_path.exists():
try:
with open(rag_db_path, "r", encoding="utf-8") as f:
rag_db = json.load(f)
# 精确匹配
for key, value in rag_db.items():
if key.lower() == family_lower:
print(f"✅ 从本地Pfam映射找到精确匹配: {key} -> {value}")
return value
# 模糊匹配(包含关系)
for key, value in rag_db.items():
if family_lower in key.lower() or key.lower() in family_lower:
print(f"🔍 本地Pfam映射模糊匹配: {key} -> {value}")
return value
print(f"⚠️ 本地Pfam映射中未找到基因家族: {gene_family_name}")
except Exception as e:
print(f"❌ 读取本地Pfam映射失败: {e}")
else:
print(f"⚠️ 本地Pfam映射不存在: {rag_db_path}")
inferred = query_pfam_from_rag_service(gene_family_name)
if inferred:
return inferred
print(f"❌ 未能从本地映射或RAG服务获取基因家族Pfam: {gene_family_name}")
return None
def query_pfam_from_rag_service(gene_family_name: str) -> Optional[str]:
"""Ask the running RAG API for a Pfam accession and extract PFxxxxx IDs."""
if os.environ.get("BBA_DISABLE_RAG_PFAM_LOOKUP", "").lower() in {"1", "true", "yes"}:
print("ℹ️ 已通过 BBA_DISABLE_RAG_PFAM_LOOKUP 禁用RAG Pfam查询")
return None
family_url = os.environ.get("BBA_RAG_FAMILY_PFAM_URL", "http://localhost:8000/api/v1/rag/family/pfam")
timeout = float(os.environ.get("BBA_RAG_PFAM_TIMEOUT", "20"))
try:
import urllib.parse
import urllib.request
url = f"{family_url}?family={urllib.parse.quote(gene_family_name)}"
print(f"🔎 本地映射未命中,尝试RAG结构化Pfam接口: {url}")
with urllib.request.urlopen(url, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
pfam_ids = [str(x).upper() for x in data.get("pfam_ids", []) if re.match(r"^PF\d{5}$", str(x), re.I)]
if pfam_ids:
inferred = ",".join(dict.fromkeys(pfam_ids))
print(f"✅ 从RAG结构化接口获取Pfam: {gene_family_name} -> {inferred}")
return inferred
print(f"⚠️ RAG结构化Pfam接口未返回PFxxxxx编号: {gene_family_name}")
except Exception as e:
print(f"⚠️ RAG结构化Pfam接口查询失败,回退到chat检索: {e}")
rag_url = os.environ.get("BBA_RAG_PFAM_URL", "http://localhost:8000/api/v1/rag/chat")
query = f"{gene_family_name}基因家族的Pfam号是多少"
payload = {
"query": query,
"mode": "agentic",
"top_k": 15,
"include_sources": True,
"use_llm": False,
}
try:
import urllib.request
req = urllib.request.Request(
rag_url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
print(f"🔎 本地映射未命中,尝试RAG服务查询Pfam: {query}")
with urllib.request.urlopen(req, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
except Exception as e:
print(f"⚠️ RAG服务Pfam查询失败: {e}")
return None
text_parts = [str(data.get("response", ""))]
for source in data.get("sources", []) or []:
if isinstance(source, dict):
text_parts.append(str(source.get("content", "")))
text_parts.append(json.dumps(source.get("metadata", {}), ensure_ascii=False))
pfam_ids = []
seen = set()
for match in re.findall(r"\bPF\d{5}\b", "\n".join(text_parts), flags=re.I):
pfam_id = match.upper()
if pfam_id not in seen:
seen.add(pfam_id)
pfam_ids.append(pfam_id)
if not pfam_ids:
print(f"⚠️ RAG服务未返回明确PFxxxxx编号: {gene_family_name}")
return None
inferred = ",".join(pfam_ids)
print(f"✅ 从RAG服务推断Pfam: {gene_family_name} -> {inferred}")
return inferred
def parse_gene_family_from_prefix(prefix: str) -> str:
"""从prefix中提取基因家族名称"""
# 例如: "At.WRKY" -> "WRKY", "WRKY.At" -> "WRKY"
parts = prefix.split('.')
for part in parts:
if len(part) > 1 and not part[0].isdigit():
# 返回第一个看起来像基因家族名称的部分
return part
return parts[-1] if parts else prefix
def normalize_identify_prefix(prefix: str, gene_family: str) -> str:
"""Normalize target-scope identify labels and reject placeholder tokens."""
family = _clean_config_text(gene_family)
provided = _clean_config_text(prefix)
if not family:
return provided
if not provided:
return f"Target.{family}"
parts = [_clean_config_text(p) for p in provided.split(".") if _clean_config_text(p)]
if not parts:
return f"Target.{family}"
role = parts[0].lower()
suffix = _clean_config_text(parts[-1]) if len(parts) >= 2 else ""
if role == "multi":
return f"Target.{family}"
if role == "target" and suffix.lower() in {"", "nan", "none", "null", "undefined", "unknown", "family"}:
return f"Target.{family}"
return provided
def run_cmd(cmd: List[str], cwd: Path = None, env=None, shell: bool = False) -> Tuple[int, str, str]:
"""运行命令并返回 (rc, stdout, stderr)"""
print(f"CMD: {' '.join(cmd)}")
if cwd:
print(f"CWD: {cwd}")
if shell:
# shell模式下,cmd可以是字符串
print(f"SHELL CMD: {cmd}")
p = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd, env=env)
else:
p = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd, env=env)
if p.stdout.strip():
print(p.stdout)
if p.stderr.strip():
print(p.stderr)
return p.returncode, p.stdout, p.stderr
def sanitize_fasta_for_interproscan(pep_file: Path, clean_fasta: Path) -> Dict[str, Any]:
"""Create an InterProScan-safe FASTA by removing stop markers and non-amino-acid residue symbols."""
stats = {
"source": str(pep_file),
"clean_fasta": str(clean_fasta),
"sequence_count": 0,
"written_sequences": 0,
"modified_sequences": 0,
"removed_asterisks": 0,
"removed_invalid_residues": 0,
"skipped_empty_sequences": 0,
}
allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
clean_fasta.parent.mkdir(parents=True, exist_ok=True)
def flush_record(header: str, chunks: List[str], outfile) -> None:
if not header:
return
header_text = header[1:].strip() if header.startswith(">") else header.strip()
if not header_text:
stats["modified_sequences"] += 1
stats["skipped_empty_sequences"] += 1
return
stats["sequence_count"] += 1
raw_seq = "".join(chunks).replace(" ", "").replace(" ", "").upper()
clean_chars: List[str] = []
removed_asterisks = 0
removed_invalid = 0
for char in raw_seq:
if char in allowed:
clean_chars.append(char)
elif char == "*":
removed_asterisks += 1
elif char in {"\n", "\r"}:
continue
else:
removed_invalid += 1
clean_seq = "".join(clean_chars)
if removed_asterisks or removed_invalid:
stats["modified_sequences"] += 1
stats["removed_asterisks"] += removed_asterisks
stats["removed_invalid_residues"] += removed_invalid
if not clean_seq:
stats["skipped_empty_sequences"] += 1
return
outfile.write(header.rstrip("\n") + "\n")
for idx in range(0, len(clean_seq), 60):
outfile.write(clean_seq[idx:idx + 60] + "\n")
stats["written_sequences"] += 1
header = ""
chunks: List[str] = []
with open(pep_file, "r", encoding="utf-8", errors="ignore") as infile, open(clean_fasta, "w", encoding="utf-8") as outfile:
for line in infile:
if line.startswith(">"):
flush_record(header, chunks, outfile)
header = line.rstrip("\n")
chunks = []
else:
chunks.append(line.strip())
flush_record(header, chunks, outfile)
return stats
def prepare_fasta_for_hmmsearch(pep_file: Path, clean_fasta: Path, max_length: int = 100000) -> Dict[str, Any]:
"""Create an HMMER-safe protein FASTA and skip records that exceed hmmsearch limits."""
stats: Dict[str, Any] = {
"source": str(pep_file),
"clean_fasta": str(clean_fasta),
"sequence_count": 0,
"written_sequences": 0,
"skipped_long_sequences": 0,
"modified_sequences": 0,
"removed_invalid_residues": 0,
"max_length": max_length,
"skipped_ids": [],
}
clean_fasta.parent.mkdir(parents=True, exist_ok=True)
allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
def flush_record(header: str, chunks: List[str], outfile) -> None:
if not header:
return
header_id_text = header[1:].strip() if header.startswith(">") else header.strip()
if not header_id_text:
stats["modified_sequences"] += 1
return
stats["sequence_count"] += 1
seq_id = header_id_text.split()[0]
raw_seq = "".join(chunks).replace(" ", "").replace("\t", "").upper()
clean_chars = []
removed = 0
for char in raw_seq:
if char in allowed:
clean_chars.append(char)
elif char in {"\n", "\r"}:
continue
else:
removed += 1
clean_seq = "".join(clean_chars)
if removed:
stats["removed_invalid_residues"] += removed
stats["modified_sequences"] += 1
if len(clean_seq) > max_length:
stats["skipped_long_sequences"] += 1
if len(stats["skipped_ids"]) < 50:
stats["skipped_ids"].append({"id": seq_id, "length": len(clean_seq)})
return
if not clean_seq:
stats["modified_sequences"] += 1
return
outfile.write(header.rstrip("\n") + "\n")
for idx in range(0, len(clean_seq), 60):
outfile.write(clean_seq[idx:idx + 60] + "\n")
stats["written_sequences"] += 1
header = ""
chunks: List[str] = []
with open(pep_file, "r", encoding="utf-8", errors="ignore") as infile, open(clean_fasta, "w", encoding="utf-8") as outfile:
for line in infile:
if line.startswith(">"):
flush_record(header, chunks, outfile)
header = line.rstrip("\n")
chunks = []
else:
chunks.append(line.strip())
flush_record(header, chunks, outfile)
report_file = clean_fasta.with_suffix(clean_fasta.suffix + ".sanitize.json")
report_file.write_text(json.dumps(stats, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
stats["report"] = str(report_file)
return stats
def summarize_interproscan_tsv(tsv_file: Path) -> Dict[str, Any]:
"""Return small summary metrics for an InterProScan TSV."""
summary = {"rows": 0, "proteins_with_hits": 0, "interpro_entries": 0, "signatures": 0}
proteins = set()
interpros = set()
signatures = set()
if not tsv_file.exists():
return summary
with open(tsv_file, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
if not line.strip() or line.startswith("#"):
continue
cols = line.rstrip("\n").split("\t")
summary["rows"] += 1
if cols:
proteins.add(cols[0])
if len(cols) > 4 and cols[4]:
signatures.add(cols[4])
if len(cols) > 11 and cols[11]:
interpros.add(cols[11])
summary.update({
"proteins_with_hits": len(proteins),
"interpro_entries": len(interpros),
"signatures": len(signatures),
})
return summary
def write_interproscan_status_file(status_file: Path, result: Dict[str, Any]) -> None:
"""Write a small human-readable status file so the frontend always has a visible domain-check artifact."""
status_file.parent.mkdir(parents=True, exist_ok=True)
lines = [
"InterProScan domain check",
f"status: {result.get('status', 'unknown')}",
f"input: {result.get('input', '')}",
f"output: {result.get('output', '')}",
f"tool: {result.get('tool', '')}",
f"conda_env: {result.get('conda_env', '')}",
]
if result.get("clean_input"):
lines.append(f"clean_input: {result.get('clean_input')}")
if result.get("sanitize"):
s = result["sanitize"]
lines.append(
"sanitize: "
f"sequences={s.get('sequence_count', 0)}, "
f"written={s.get('written_sequences', s.get('sequence_count', 0))}, "
f"modified={s.get('modified_sequences', 0)}, "
f"removed_asterisks={s.get('removed_asterisks', 0)}, "
f"removed_invalid={s.get('removed_invalid_residues', 0)}"
)
if result.get("summary"):
s = result["summary"]
lines.append(
"summary: "
f"rows={s.get('rows', 0)}, proteins_with_hits={s.get('proteins_with_hits', 0)}, "
f"interpro_entries={s.get('interpro_entries', 0)}, signatures={s.get('signatures', 0)}"
)
if result.get("returncode") is not None:
lines.append(f"returncode: {result.get('returncode')}")
if result.get("elapsed_sec") is not None:
lines.append(f"elapsed_sec: {result.get('elapsed_sec')}")
if result.get("reason"):
lines.extend(["", "reason:", str(result.get("reason"))])
status_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
def read_id_list(id_file: Path) -> List[str]:
ids: List[str] = []
seen: Set[str] = set()
if not id_file.exists():
return ids
with id_file.open("r", encoding="utf-8", errors="ignore") as handle:
for line in handle:
text = line.strip()
if not text:
continue
seq_id = text.split()[0]
if seq_id and seq_id not in seen:
ids.append(seq_id)
seen.add(seq_id)
return ids
def write_id_list(ids: List[str], output_file: Path) -> int:
output_file.parent.mkdir(parents=True, exist_ok=True)
with output_file.open("w", encoding="utf-8") as handle:
for seq_id in ids:
handle.write(f"{seq_id}\n")
return len(ids)
def write_domain_review_from_interproscan(
interproscan_tsv: Path,
required_domains: List[str],
output_tsv: Path,
species: str = "",
id_filter: Optional[Set[str]] = None,
) -> Dict[str, Any]:
"""Write a compact review table for member confirmation after InterProScan."""
result = {
"status": "skipped",
"input": str(interproscan_tsv),
"output": str(output_tsv),
"required_domains": required_domains,
"species": species,
"default_keep_ids": [],
}
if not interproscan_tsv.exists():
result["reason"] = f"InterProScan TSV not found: {interproscan_tsv}"
return result
required = {str(item).upper() for item in required_domains if str(item).strip()}
header = [
"gene_id", "species", "analysis", "signature_accession", "signature_description",
"interpro_accession", "interpro_description", "start", "end", "protein_length",
"domain_coverage", "score_or_evalue", "required_domain", "default_keep",
]
rows = 0
proteins: Set[str] = set()
default_keep: Set[str] = set()
output_tsv.parent.mkdir(parents=True, exist_ok=True)
with interproscan_tsv.open("r", encoding="utf-8", errors="ignore") as src, output_tsv.open("w", encoding="utf-8") as out:
out.write("\t".join(header) + "\n")
for line in src:
if not line.strip():
continue
cols = line.rstrip("\n").split("\t")
if len(cols) < 11:
continue
gene_id = cols[0].strip()
if id_filter is not None and gene_id not in id_filter:
continue
analysis = cols[3].strip() if len(cols) > 3 else ""
signature = cols[4].strip() if len(cols) > 4 else ""
signature_desc = cols[5].strip() if len(cols) > 5 else ""
start = cols[6].strip() if len(cols) > 6 else ""
end = cols[7].strip() if len(cols) > 7 else ""
score = cols[8].strip() if len(cols) > 8 else ""
protein_length = cols[2].strip() if len(cols) > 2 else ""
interpro_acc = cols[11].strip() if len(cols) > 11 else ""
interpro_desc = cols[12].strip() if len(cols) > 12 else ""
coverage = ""
try:
length = float(protein_length)
begin = float(start)
finish = float(end)
if length > 0 and finish >= begin:
coverage = f"{((finish - begin + 1) / length):.4f}"
except Exception:
coverage = ""
required_hit = bool(required) and (
signature.upper() in required or interpro_acc.upper() in required
)
if required_hit:
default_keep.add(gene_id)
proteins.add(gene_id)
rows += 1
out.write("\t".join([
gene_id, species, analysis, signature, signature_desc,
interpro_acc, interpro_desc, start, end, protein_length,
coverage, score, "TRUE" if required_hit else "FALSE",
"TRUE" if required_hit else "FALSE",
]) + "\n")
result.update({
"status": "success",
"rows": rows,
"proteins_with_hits": len(proteins),
"default_keep_count": len(default_keep),
"default_keep_ids": sorted(default_keep),
"full_path": str(output_tsv.resolve()),
"relpath_or_hint": f"final/{output_tsv.name}",
"size": output_tsv.stat().st_size if output_tsv.exists() else 0,
})
return result
def run_interproscan_domain_check(pep_file: Path, output_tsv: Path) -> Dict[str, Any]:
"""Run InterProScan on final members as a non-blocking domain check artifact."""
default_interproscan = PROJECT_ROOT / "data" / "tools" / "interproscan5.77" / "interproscan-5.77-108.0" / "interproscan.sh"
script = Path(os.environ.get(
"BBA_INTERPROSCAN_SH",
str(default_interproscan),
))
conda_env = os.environ.get("BBA_INTERPROSCAN_ENV", "interproscanpy311")
conda_sh = Path(os.environ.get("BBA_CONDA_SH", "/home/xzq/miniconda3/etc/profile.d/conda.sh"))
conda_root = conda_sh.parents[2] if len(conda_sh.parents) >= 3 else Path("/home/xzq/miniconda3")
env_prefix = Path(os.environ.get("BBA_INTERPROSCAN_PREFIX", str(conda_root / "envs" / conda_env)))
java_home = Path(os.environ.get("BBA_INTERPROSCAN_JAVA_HOME", str(env_prefix / "lib" / "jvm")))
result = {
"artifact": "interproscan_domain_check",
"status": "skipped",
"input": str(pep_file),
"output": str(output_tsv),
"tool": str(script),
"conda_env": conda_env,
"java_home": str(java_home),
}
if not pep_file.exists():
result["reason"] = f"input FASTA not found: {pep_file}"
print(f"InterProScan domain check skipped: {result['reason']}")
return result
if not script.exists():
result["reason"] = f"interproscan.sh not found: {script}"
print(f"InterProScan domain check skipped: {result['reason']}")
return result
if not conda_sh.exists():
result["reason"] = f"conda activation script not found: {conda_sh}"
print(f"InterProScan domain check skipped: {result['reason']}")
return result
java_bin = java_home / "bin" / "java"
if not java_bin.exists():
result["reason"] = f"Java executable not found: {java_bin}"
print(f"InterProScan domain check skipped: {result['reason']}")
return result
output_tsv.parent.mkdir(parents=True, exist_ok=True)
if output_tsv.exists():
output_tsv.unlink()
clean_fasta = output_tsv.with_suffix(".input.pep.fa")
sanitize_stats = sanitize_fasta_for_interproscan(pep_file, clean_fasta)
scan_input = clean_fasta
result["clean_input"] = str(scan_input)
result["sanitize"] = sanitize_stats
if sanitize_stats.get("written_sequences", sanitize_stats.get("sequence_count", 0)) == 0:
result["reason"] = f"input FASTA has no usable sequences after identify: {pep_file}"
print(f"InterProScan domain check skipped: {result['reason']}")
return result
cmd = (
f"source {shlex.quote(str(conda_sh))} && "
f"conda activate {shlex.quote(conda_env)} && "
f"export JAVA_HOME={shlex.quote(str(java_home))} && "
f"export PATH={shlex.quote(str(java_home / 'bin'))}:$PATH && "
"java -version && "
f"{shlex.quote(str(script))} -appl Pfam,CDD -i {shlex.quote(str(scan_input))} -f TSV -o {shlex.quote(str(output_tsv))} && "
"conda deactivate"
)
print("Running InterProScan domain check:")
print(f" {cmd}")
start = time.time()
proc = subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True)
elapsed = round(time.time() - start, 2)
if proc.stdout.strip():
print(proc.stdout)
if proc.stderr.strip():
print(proc.stderr)
result.update({
"returncode": proc.returncode,
"elapsed_sec": elapsed,
"cmd": cmd,
})
if proc.returncode == 0 and output_tsv.exists():
summary = summarize_interproscan_tsv(output_tsv)
result.update({
"status": "success",
"full_path": str(output_tsv.resolve()),
"relpath_or_hint": f"final/{output_tsv.name}",
"size": output_tsv.stat().st_size,
"summary": summary,
})
print(f"InterProScan domain check completed: {output_tsv}")
else:
result.update({
"status": "failed",
"reason": (proc.stderr or proc.stdout or "InterProScan failed without output")[-2000:],
})
print(f"InterProScan domain check failed, but identify will continue: {result['reason']}")
return result
def prepare_directories(workspace: Path) -> Dict[str, Path]:
"""创建Identify阶段的标准目录结构"""
paths = {
"analysis_dir": workspace / "identify" / "analysis",
"combine_dir": workspace / "identify" / "combine",
"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 write_manifest(manifest_path: Path, data: dict):
"""写入 manifest.json"""
manifest_path.parent.mkdir(parents=True, exist_ok=True)
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"Manifest: {manifest_path}")
def write_failed_identify_manifest(
workspace: Path,
prefix: str,
gene_family: str,
input_dir: Path,
start_time: datetime,
error: str,
parameters: Optional[Dict[str, Any]] = None,
hmm_models: Optional[Dict[str, Any]] = None,
) -> Path:
"""Write a minimal failed identify manifest so backend/diagnosis can gate downstream stages."""
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
manifest_path = final_dir / f"{prefix}.identify.manifest.json"
manifest = {
"manifest_version": "1.0",
"job_id": workspace.name,
"run_id": f"identify_{datetime.now():%Y%m%d_%H%M%S}",
"stage": "identify",
"prefix": prefix,
"output_prefix": prefix,
"result_group": "gene_family_identify",
"gene_family": gene_family,
"status": "failed",
"timestamp": datetime.now().isoformat(),
"start_time": start_time.isoformat(),
"end_time": datetime.now().isoformat(),
"elapsed_sec": (datetime.now() - start_time).total_seconds(),
"workspace_root": str(workspace.resolve()),
"workspace": str(final_dir.resolve()),
"input_dir": str(input_dir.resolve()),
"cmd": sys.argv,
"parameters": parameters or {},
"metrics": {},
"hmm_models": hmm_models or {},
"error": error,
"warnings": [
{"step": "reference_policy", "message": warning}
for warning in (reference_policy.get("warnings") or [])
],
"artifacts": {},
}
write_manifest(manifest_path, manifest)
return manifest_path
def _is_user_hmm_name(filename: str) -> bool:
"""Return True for user query HMM files, excluding bundled/system Pfam DBs."""
n = filename.lower()
if 'pfam-a' in n or 'pfam_a' in n:
return False
return n.endswith('.hmm') or n.endswith('.hmm.gz')
def _decompress_hmm_gz(hmm_gz: Path, cache_dir: Path) -> Path:
"""Decompress a user-uploaded .hmm.gz into a stable cache and return .hmm path."""
cache_dir.mkdir(parents=True, exist_ok=True)
out_name = hmm_gz.name[:-3] # PFxxxxx.hmm.gz -> PFxxxxx.hmm
out_path = cache_dir / out_name
if out_path.exists() and out_path.stat().st_mtime >= hmm_gz.stat().st_mtime:
print(f" Using decompressed HMM cache: {out_path}")
return out_path
print(f" Decompressing user HMM: {hmm_gz.name} -> {out_path}")
with gzip.open(hmm_gz, 'rb') as src, open(out_path, 'wb') as dst:
shutil.copyfileobj(src, dst)
return out_path
def find_user_query_hmms(input_dir: Path) -> List[Path]:
"""Find user-uploaded .hmm/.hmm.gz files usable by hmmsearch."""
print(f"Searching user HMM files in: {input_dir}")
hmms = []
cache_dir = input_dir / "genome_prepare" / "hmm_cache"
for p in input_dir.iterdir():
if not p.is_file():
continue
if not _is_user_hmm_name(p.name):
continue
n = p.name.lower()
if n.endswith('.hmm.gz'):
hmm_path = _decompress_hmm_gz(p, cache_dir)
hmms.append((hmm_path, p.stat().st_mtime))
print(f" Found compressed user HMM: {p.name}")
else:
hmms.append((p, p.stat().st_mtime))
print(f" Found user HMM: {p.name}")
if not hmms:
print(" No user-uploaded HMM files found")
return []
hmms.sort(key=lambda item: item[1], reverse=True)
result = [item[0] for item in hmms]
print(f"Found {len(result)} user HMM file(s)")
return result
def find_pep_file(input_dir: Path) -> Optional[Path]:
"""
在 input_dir 中查找蛋白质序列文件
支持多种命名格式:
- *.pep.fasta, *.pep.fa
- *.protein.fasta, *.protein.fa
- *.faa
- 或者包含 "pep" 或 "protein" 的fasta文件
"""
print(f"🔍 查找蛋白质序列文件在: {input_dir}")
patterns = ["*.pep.fasta", "*.pep.fa", "*.protein.fasta", "*.protein.fa", "*.faa", "*.fasta"]
for pattern in patterns:
for file in input_dir.rglob(pattern):
if file.is_file():
# 检查文件名是否包含pep或protein(不区分大小写)
filename = file.name.lower()
if "pep" in filename or "protein" in filename:
print(f"✅ 找到蛋白质序列文件: {file}")
return file
# 对于通用的.fasta或.faa文件,也考虑
if pattern in ["*.fasta", "*.faa"] and file.stat().st_size > 10000: # 大于10KB
print(f"✅ 找到可能的蛋白质序列文件: {file}")
return file
print("❌ 未找到蛋白质序列文件")
return None
def _clean_config_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
text = str(value).strip().lower()
return text in {"true", "1", "yes", "y", "ref"}
def _clean_config_text(value: Any) -> str:
if value is None:
return ""
try:
import pandas as pd
if pd.isna(value):
return ""
except Exception:
pass
text = str(value).strip()
return "" if text.lower() in {"nan", "none", "null", "undefined", "-"} else text
def _config_file_candidates(input_dir: Path, workspace: Optional[Path] = None) -> List[Path]:
candidates = [
input_dir / "config.xlsx",
input_dir / "config.autofill.draft.xlsx",
]
if workspace:
candidates.extend([
workspace / "final" / "config.xlsx",
workspace / "final" / "config.autofill.draft.xlsx",
workspace / "config.xlsx",
workspace / "config.autofill.draft.xlsx",
])
seen: Set[str] = set()
result: List[Path] = []
for path in candidates:
key = str(path.resolve()) if path.exists() else str(path)
if key in seen:
continue
seen.add(key)
if path.exists():
result.append(path)
return result
def read_analysis_family(input_dir: Path, workspace: Optional[Path] = None) -> str:
"""Read the job-level family value from config.xlsx Analysis sheet or config columns."""
config_paths = _config_file_candidates(input_dir, workspace)
if not config_paths:
return ""
try:
import pandas as pd
for config_path in config_paths:
xls = pd.ExcelFile(config_path)
for sheet in xls.sheet_names:
if sheet.strip().lower() not in {"analysis", "project", "metadata"}:
continue
df = pd.read_excel(config_path, sheet_name=sheet, header=None)
for _, row in df.iterrows():
key = _clean_config_text(row.iloc[0] if len(row) > 0 else "").lower()
value = _clean_config_text(row.iloc[1] if len(row) > 1 else "")
if key in {"family", "gene_family", "analysis_family", "project_family"} and value:
print(f"🧬 从 {config_path.name}/{sheet} 读取家族: {value}")
return value
df = pd.read_excel(config_path)
for col in ["Family", "Analysis_Family", "Project_Family", "Gene_Family"]:
if col not in df.columns:
continue
values = [_clean_config_text(v) for v in df[col].tolist()]
values = [v for v in values if v]
unique = sorted(set(values))
if len(unique) == 1:
print(f"🧬 从 {config_path.name} 列 {col} 读取家族: {unique[0]}")
return unique[0]
except Exception as exc:
print(f"⚠️ 读取 config.xlsx 家族字段失败: {exc}")
return ""
def read_required_pfams_from_config(input_dir: Path, workspace: Optional[Path] = None) -> str:
"""Read Required_Pfams from config.xlsx Analysis sheet or config columns."""
config_paths = _config_file_candidates(input_dir, workspace)
if not config_paths:
return ""
try:
import pandas as pd
for config_path in config_paths:
xls = pd.ExcelFile(config_path)
for sheet in xls.sheet_names:
if sheet.strip().lower() not in {"analysis", "project", "metadata"}:
continue
df = pd.read_excel(config_path, sheet_name=sheet, header=None)
for _, row in df.iterrows():
key = _clean_config_text(row.iloc[0] if len(row) > 0 else "").lower()
value = _clean_config_text(row.iloc[1] if len(row) > 1 else "")
if key in {"required_pfams", "required_pfam", "pfam", "pfams", "pfam_ids"} and value:
print(f"🧬 从 {config_path.name}/{sheet} 读取 Required_Pfams: {value}")
return value
df = pd.read_excel(config_path)
for col in ["Required_Pfams", "Required_Pfam", "Pfam", "Pfams", "Pfam_IDs", "PFAM"]:
if col not in df.columns:
continue
values = [_clean_config_text(v) for v in df[col].tolist()]
values = [v for v in values if v]
if values:
merged = ",".join(dict.fromkeys(
item.strip()
for value in values
for item in re.split(r"[,;,;\s]+", value)
if item.strip()
))
if merged:
print(f"🧬 从 {config_path.name} 列 {col} 读取 Required_Pfams: {merged}")
return merged
except Exception as exc:
print(f"⚠️ 读取 config.xlsx Required_Pfams 失败: {exc}")
return ""
def _input_search_dirs(input_dir: Path, workspace: Optional[Path] = None) -> List[Path]:
dirs = [input_dir]
if workspace:
dirs.extend([workspace / "final", workspace])
dirs.append(input_dir / "final")
seen: Set[str] = set()
result: List[Path] = []
for d in dirs:
key = str(d.resolve()) if d.exists() else str(d)
if key not in seen:
result.append(d)
seen.add(key)
return result
def _resolve_config_file(
input_dir: Path,
species: str,
family: str,
file_type: str,
original_name: str = "",
search_dirs: Optional[List[Path]] = None,
) -> Optional[Path]:
"""Resolve standardized or original files declared in config.xlsx."""
if file_type == "all_pep":
patterns = [
f"{species}.{family}.all_pep.fasta", f"{species}.{family}.all_pep.fa",
f"{species}.{family}.pep.fasta", f"{species}.{family}.pep.fa",
f"{species}.{family}.protein.fasta", f"{species}.{family}.faa",
f"{species}.pep.fasta", f"{species}.pep.fa",
f"{species}.protein.fasta", f"{species}.protein.fa", f"{species}.faa",
]
elif file_type == "family_pep":
patterns = [
f"{species}.{family}.family_pep.fasta", f"{species}.{family}.family_pep.fa",
f"{species}.{family}.pep.fasta", f"{species}.{family}.pep.fa",
]
elif file_type == "pep":
patterns = [f"{species}.{family}.pep.fasta", f"{species}.{family}.pep.fa"]
else:
patterns = []
if original_name:
patterns.append(Path(original_name).name)
for pattern in patterns:
for root in (search_dirs or [input_dir]):
for candidate in (root / pattern, root / species / pattern):
if candidate.exists():
return candidate
return None
def is_likely_protein_fasta(path: Path, max_first_seq_len: int = 100000, sample_records: int = 3) -> Tuple[bool, str]:
"""Lightweight guard against genome/promoter FASTA files mislabeled as pep."""
if not path.exists() or path.stat().st_size == 0:
return False, "file missing or empty"
records = 0
current_id = ""
current_len = 0
saw_header = False
try:
with path.open("r", encoding="utf-8", errors="ignore") as handle:
for raw in handle:
if raw.startswith(">"):
if saw_header:
records += 1
if current_len > max_first_seq_len:
return False, f"{current_id} length {current_len} > {max_first_seq_len}"
if records >= sample_records:
return True, f"sampled {records} protein-like records"
saw_header = True
current_id = raw[1:].strip().split()[0]
current_len = 0
if re.match(r"^(chr|chromosome|scaffold|contig)_?\d*", current_id, re.I):
# Chromosome IDs are not decisive alone, but combined with length threshold below
# they catch common genome FASTA mislabels quickly.
pass
continue
if not saw_header:
continue
current_len += len(raw.strip())
if current_len > max_first_seq_len:
return False, f"{current_id or 'first_record'} length {current_len} > {max_first_seq_len}"
if saw_header:
records += 1
if current_len > max_first_seq_len:
return False, f"{current_id} length {current_len} > {max_first_seq_len}"
if records == 0:
return False, "no FASTA records"
return True, f"sampled {records} protein-like records"
except Exception as exc:
return False, f"failed to read FASTA: {exc}"
def find_pep_files(input_dir: Path, workspace: Optional[Path] = None, target_species: Optional[Set[str]] = None) -> List[Path]:
"""Find target whole-proteome FASTA files for identify.
File_Type=all_pep is searched; File_Type=family_pep is reference-only.
Legacy target File_Type=pep is kept as a fallback.
"""
print(f"?? ?????????????: {input_dir}")
target_peps: List[Path] = []
seen: Set[str] = set()
search_dirs = _input_search_dirs(input_dir, workspace)
config_path = input_dir / "config.xlsx"
if config_path.exists():
try:
import pandas as pd
df = pd.read_excel(config_path)
roles_by_species: Dict[str, Set[str]] = {}
for _, row in df.iterrows():
species = _clean_config_text(row.get("Species_Code", ""))
role = _clean_config_text(row.get("Species_Role", "")).lower()
if species and role:
roles_by_species.setdefault(species, set()).add(role)
for _, row in df.iterrows():
raw_file_type = _clean_config_text(row.get("File_Type", "")).lower()
is_ref = _clean_config_bool(row.get("Ref", False))
role = _clean_config_text(row.get("Species_Role", "")).lower()
scope = _clean_config_text(row.get("File_Scope", "")).lower()
species = _clean_config_text(row.get("Species_Code", ""))
family = _clean_config_text(row.get("Gene_Family", ""))
original = _clean_config_text(row.get("Original_File_Name", ""))
forced_target = bool(target_species and species in target_species)
file_type = "all_pep" if raw_file_type in {"protein", "all_pep"} else raw_file_type
if raw_file_type == "pep" and not (scope in {"known_family", "family", "seed"} or (is_ref and _clean_config_text(row.get("Gene_Family", "")) and role in {"", "seed", "seed_reference"})):
file_type = "all_pep"
seed_role = role in {"seed", "seed_reference", "known_family_reference"}
legacy_seed_ref = is_ref and role == "" and _clean_config_text(row.get("Gene_Family", "")) and raw_file_type in {"pep", "family_pep"}
if file_type not in {"all_pep", "pep", "protein"}:
continue
if not species:
continue
if target_species and species not in target_species:
continue
if not forced_target and (is_ref or seed_role or legacy_seed_ref):
continue
species_roles = roles_by_species.get(species, set())
if not forced_target and "ref" in species_roles and "target" not in species_roles:
print(f" - 跳过参考物种全蛋白: {species}")
continue
if not forced_target and role and role not in {"target", "comparative", "compactive"}:
continue
resolved = _resolve_config_file(input_dir, species, family, "all_pep", original, search_dirs)
if resolved and str(resolved.resolve()) not in seen:
protein_ok, protein_reason = is_likely_protein_fasta(resolved)
if not protein_ok:
print(f" - 跳过疑似非蛋白FASTA: {species} -> {resolved.name} ({protein_reason})")
continue
target_peps.append(resolved)
seen.add(str(resolved.resolve()))
print(f" ✓ 目标/比较物种蛋白: {species} ({role or 'unspecified'}) -> {resolved.name}")
except Exception as exc:
print(f" ?? ?? config.xlsx ??????????: {exc}")
if not target_peps:
for pattern in ("*.all_pep.fasta", "*.all_pep.fa", "*.pep.fasta", "*.pep.fa"):
for f in input_dir.glob(pattern):
lname = f.name.lower()
if f.name.startswith("Ref_combined") or ".family_pep." in lname:
continue
if target_species and f.name.split(".", 1)[0] not in target_species:
continue
protein_ok, protein_reason = is_likely_protein_fasta(f)
if not protein_ok:
print(f" - 跳过疑似非蛋白FASTA(legacy): {f.name} ({protein_reason})")
continue
target_peps.append(f)
print(f" ? ????????(??): {f.name}")
if not target_peps:
print(" ? ??????? all_pep/pep ??")
else:
print(f" ?? ??? {len(target_peps)} ?????????")
return sorted(target_peps)
def read_config_sheet_dataframe(config_path: Path):
import pandas as pd
xls = pd.ExcelFile(config_path)
sheet_name = "config" if "config" in xls.sheet_names else xls.sheet_names[0]
return pd.read_excel(config_path, sheet_name=sheet_name)
def read_reference_policy_from_config(input_dir: Path) -> Dict[str, Any]:
policy = {
"primary_reference_species": [],
"identify_blast_reference_species": [],
"warnings": [],
}
config_path = input_dir / "config.xlsx"
if not config_path.exists():
return policy
try:
import pandas as pd
try:
analysis_df = pd.read_excel(config_path, sheet_name="Analysis", header=None)
analysis: Dict[str, str] = {}
for _, row in analysis_df.iterrows():
key = _clean_config_text(row.iloc[0] if len(row) > 0 else "")
value = _clean_config_text(row.iloc[1] if len(row) > 1 else "")
if key:
analysis[key] = value
primary = analysis.get("Primary_Reference_Species") or analysis.get("Reference_Species") or ""
identify_refs = analysis.get("Identify_Blast_Reference_Species") or ""
policy["primary_reference_species"] = [x.strip() for x in re.split(r"[,;,、\s]+", primary) if x.strip()]
policy["identify_blast_reference_species"] = [x.strip() for x in re.split(r"[,;,、\s]+", identify_refs) if x.strip()]
if analysis.get("Reference_Species") and not analysis.get("Primary_Reference_Species"):
policy["warnings"].append("Using legacy Analysis.Reference_Species as primary downstream reference; prefer Primary_Reference_Species.")
except Exception:
pass
df = read_config_sheet_dataframe(config_path)
if "Reference_Use" in df.columns:
row_identify_refs: List[str] = []
row_primary_refs: List[str] = []
for _, row in df.iterrows():
species = _clean_config_text(row.get("Species_Code", ""))
use = _clean_config_text(row.get("Reference_Use", "")).lower().replace("-", "_").replace(" ", "_")
if not species:
continue
if use in {"identify", "blast", "identify_blast", "identify_reference", "both", "all"}:
row_identify_refs.append(species)
if use in {"primary", "downstream", "downstream_primary", "primary_reference", "both", "all"}:
row_primary_refs.append(species)
if not policy["identify_blast_reference_species"] and row_identify_refs:
policy["identify_blast_reference_species"] = list(dict.fromkeys(row_identify_refs))
if not policy["primary_reference_species"] and row_primary_refs:
policy["primary_reference_species"] = list(dict.fromkeys(row_primary_refs))
except Exception as exc:
policy["warnings"].append(f"Failed to read reference policy from config.xlsx: {exc}")
return policy
def find_reference_file_simple(input_dir: Path, gene_family: str = "", workspace: Optional[Path] = None) -> Optional[Path]:
"""
简化版:查找参考物种合并文件
命名规范: Ref_combined.{gene_family}.pep.fasta
Args:
input_dir: 输入目录
Returns:
参考物种 pep 文件路径,未找到返回 None
"""
print(f"🔍 查找参考物种文件在: {input_dir}")
search_dirs = _input_search_dirs(input_dir, workspace)
family = (gene_family or "").strip()
family_patterns = []
if family:
family_patterns.extend([
f"*.{family}.ncbi_fetch.protein.fa",
f"*.{family}.ncbi_fetch.protein.fasta",
f"*.{family}.family_pep.fa",
f"*.{family}.family_pep.fasta",
f"*.{family}.pep.fa",
f"*.{family}.pep.fasta",
f"Ref_combined.{family}.pep.fasta",
])
family_patterns.append("Ref_combined.*.pep.fasta")
for root in search_dirs:
for pattern in family_patterns:
ref_files = sorted(root.glob(pattern), key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
if ref_files:
print(f" ✅ 参考家族蛋白: {ref_files[0].name} ({root})")
return ref_files[0]
ref_files = list(input_dir.glob("Ref_combined.*.pep.fasta"))
if ref_files:
print(f" ✅ 参考物种: {ref_files[0].name}")
return ref_files[0]
print(" ❌ 未找到参考物种文件 Ref_combined.*.pep.fasta")
return None
def infer_reference_label(ref_file: Path, gene_family: str = "") -> str:
"""Infer a stable reference set label from a family protein FASTA name."""
stem = Path(ref_file).name
for suffix in (".fasta", ".fa", ".faa"):
if stem.lower().endswith(suffix):
stem = stem[: -len(suffix)]
break
for suffix in (".pep", ".protein", ".family_pep", ".ncbi_fetch.protein"):
stem = re.sub(re.escape(suffix) + r"$", "", stem, flags=re.IGNORECASE)
if gene_family:
stem = re.sub(rf"([._-]){re.escape(gene_family)}$", "", stem, flags=re.IGNORECASE)
stem = re.sub(rf"{re.escape(gene_family)}$", "", stem, flags=re.IGNORECASE)
stem = stem.strip("._-") or Path(ref_file).stem
return sanitize_token(stem)
def collect_reference_files(
input_dir: Path,
gene_family: str = "",
workspace: Optional[Path] = None,
explicit_ref_file: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Collect reference family protein FASTA files declared for identify BLAST.
Web pipeline semantics: reference-protein BLAST is enabled only by config.xlsx
rows that explicitly declare known-family/seed reference proteins. File-name
discovery alone must not turn HMM-only identify into HMM+BLAST.
"""
references: List[Dict[str, Any]] = []
seen: Set[str] = set()
def add_ref(path: Path, species: str = "", source: str = "auto") -> None:
if not path:
return
path = Path(path).resolve()
if not path.exists() or not path.is_file():
raise RuntimeError(f"参考家族蛋白文件不存在: {path}")
key = str(path)
if key in seen:
return
label = sanitize_token(species) if species else infer_reference_label(path, gene_family)
references.append({
"label": label,
"species": species or label,
"path": path,
"source": source,
"records": count_fasta_records(path),
"sha256": compute_file_hash(path),
"size": path.stat().st_size,
})
seen.add(key)
if explicit_ref_file:
for raw in re.split(r"[,;]", explicit_ref_file):
text_value = raw.strip()
if text_value:
add_ref(Path(text_value), source="--ref-file")
return references
search_dirs = _input_search_dirs(input_dir, workspace)
policy = read_reference_policy_from_config(input_dir)
allowed_identify_refs = set(policy.get("identify_blast_reference_species") or [])
config_path = input_dir / "config.xlsx"
if config_path.exists():
try:
df = read_config_sheet_dataframe(config_path)
for _, row in df.iterrows():
species = _clean_config_text(row.get("Species_Code", ""))
family = _clean_config_text(row.get("Gene_Family", ""))
role = _clean_config_text(row.get("Species_Role", "")).lower()
scope = _clean_config_text(row.get("File_Scope", "")).lower()
file_type = _clean_config_text(row.get("File_Type", "")).lower()
is_ref = _clean_config_bool(row.get("Ref", False))
original = _clean_config_text(row.get("Original_File_Name", ""))
reference_use = _clean_config_text(row.get("Reference_Use", "")).lower().replace("-", "_").replace(" ", "_")
family_match = not gene_family or not family or family.lower() == gene_family.lower()
use_allows_identify = reference_use in {"identify", "blast", "identify_blast", "identify_reference", "both", "all"}
use_blocks_identify = reference_use in {"downstream", "downstream_primary", "primary", "primary_reference", "none", "no", "false"}
species_allowed = (not allowed_identify_refs) or (species in allowed_identify_refs)
known_family_row = (
scope in {"known_family", "family", "seed"}
or role in {"seed", "seed_reference", "known_family_reference"}
or use_allows_identify
)
is_family_ref = species_allowed and known_family_row and (
use_allows_identify
or not use_blocks_identify
)
is_pep = file_type in {"pep", "protein", "family_pep", "all_pep"}
if not family_match or not is_family_ref or not is_pep:
continue
resolved = _resolve_config_file(input_dir, species, family or gene_family, "family_pep", original, search_dirs)
if not resolved:
resolved = _resolve_config_file(input_dir, species, family or gene_family, "pep", original, search_dirs)
if resolved:
add_ref(resolved, species=species, source="config.xlsx")
except Exception as exc:
print(f"⚠️ 读取 config.xlsx 参考家族蛋白失败,将按无参考蛋白处理: {exc}")
if references:
print(f"📚 参考家族蛋白集合: {len(references)}")
for ref in references:
print(f" - {ref['label']}: {ref['path'].name} ({ref['records']} seqs)")
return references
return references
def extract_gene_family(input_dir: Path, prefix: str = "", workspace: Optional[Path] = None) -> str:
"""
从文件名提取基因家族名称
优先从 Ref_combined 文件提取,其次从目标文件
命名规范: {species}.{gene_family}.pep.fasta
例如: Ath.SPL.pep.fasta -> "SPL"
Args:
input_dir: 输入目录
Returns:
基因家族名称(大写)
"""
if prefix:
parts = [_clean_config_text(p) for p in prefix.split(".") if _clean_config_text(p)]
invalid_tokens = {"family", "unknown", "none", "multi", "target", "ref", "reference", "comparative"}
if len(parts) >= 2 and parts[-1].lower() not in invalid_tokens:
print(f"🧬 从 prefix 提取基因家族: {parts[-1]}")
return parts[-1]
if len(parts) == 1 and not re.match(r"^[A-Z][a-z]?$", parts[0]) and parts[0].lower() not in invalid_tokens:
print(f"🧬 从 prefix 提取基因家族: {parts[0]}")
return parts[0]
config_family = read_analysis_family(input_dir, workspace)
if config_family:
return config_family
search_dirs = _input_search_dirs(input_dir, workspace)
for root in search_dirs:
for ref_file in sorted(root.glob("*.ncbi_fetch.protein.fa"), key=lambda p: p.stat().st_mtime, reverse=True):
parts = ref_file.name.split(".")
if len(parts) >= 2:
print(f"🧬 从 NCBI 参考蛋白提取基因家族: {parts[1]}")
return parts[1]
# 优先从参考文件提取
ref_files = list(input_dir.glob("Ref_combined.*.pep.fasta"))
if ref_files:
# Ref_combined.SPL.pep.fasta -> SPL
parts = ref_files[0].stem.split('.')
if len(parts) >= 2:
# Karpathy: 保持原始大小写,不强制转大写
gene_family = parts[1]
print(f"🧬 从参考文件提取基因家族: {gene_family}")
return gene_family
# 从第一个目标文件提取
target_files = [f for f in input_dir.glob("*.pep.fasta")
if not f.name.startswith("Ref_combined")]
if target_files:
parts = target_files[0].stem.split('.')
if len(parts) >= 2:
# Karpathy: 保持原始大小写,不强制转大写
gene_family = parts[1]
print(f"🧬 从目标文件提取基因家族: {gene_family}")
return gene_family
raise RuntimeError("无法从文件名提取基因家族名称")
def count_fasta_records(fasta_file: Path) -> int:
count = 0
if not fasta_file.exists():
return count
with open(fasta_file, 'r', encoding='utf-8', errors='ignore') as handle:
for line in handle:
if line.startswith('>'):
count += 1
return count
def write_fasta_ids(fasta_file: Path, output_file: Path) -> int:
"""Write one sequence ID per FASTA record using the first header token."""
output_file.parent.mkdir(parents=True, exist_ok=True)
count = 0
with open(fasta_file, 'r') as infile, open(output_file, 'w') as outfile:
for line in infile:
if line.startswith('>'):
seq_id = line[1:].strip().split()[0]
if seq_id:
outfile.write(seq_id + '\n')
count += 1
print(f"ID list written: {output_file} ({count} IDs)")
return count
def merge_final_members(
final_member_files: List[Path],
output_file: Path
) -> None:
"""
合并所有目标物种的 final_gfmembers 文件
Args:
final_member_files: 各物种 final_gfmembers 文件列表
output_file: 合并后的输出文件
"""
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w') as outfile:
for member_file in final_member_files:
if member_file.exists():
with open(member_file, 'r') as infile:
content = infile.read()
outfile.write(content)
# 确保文件末尾有换行
if not content.endswith('\n'):
outfile.write('\n')
print(f"✅ 合并完成: {output_file} ({len(final_member_files)} 个文件)")
# def find_reference_file(input_dir: Path) -> Optional[Path]:
# """
# 在 input_dir 中查找参考序列文件
# 用于blast分析
# """
# print(f"🔍 查找参考序列文件在: {input_dir}")
# patterns = ["*.fasta", "*.fa", "*.faa"]
# for pattern in patterns:
# for file in input_dir.rglob(pattern):
# if file.is_file():
# # 排除蛋白质序列文件
# filename = file.name.lower()
# if not ("pep" in filename or "protein" in filename):
# print(f"✅ 找到参考序列文件: {file}")
# return file
# print("⚠️ 未找到参考序列文件")
# return None
def find_reference_file(input_dir: Path, prefix: str, gene_family: str = None) -> Union[Path, List[Path], None]:
"""
在 input_dir 中查找参考序列文件(排除用户数据文件)
支持命名规范: At.pep.fasta (全蛋白), At_NAC.pep.fasta (基因家族蛋白)
修订 2026-04-26: 新增对 Ref_combined.*.pep.fasta 的支持
优先使用 genome_prepare 生成的参考物种合并文件
对于identify阶段:
- 优先寻找基因家族特定的参考序列 (如 At_NAC.pep.fasta)
- 找到多个时返回列表供用户选择
- 未找到时返回None并提示用户上传
Args:
input_dir: 输入目录
prefix: 分析前缀,例如 "At.WRKY" 或 "Ft"
gene_family: 基因家族名称,如 "NAC"
"""
print(f"🔍 查找参考序列文件(排除用户文件)在: {input_dir}")
# 修订 2026-04-26: 优先检查 Ref_combined.*.pep.fasta
ref_combined_files = list(input_dir.glob("Ref_combined.*.pep.fasta"))
if ref_combined_files:
print(f"✅ 找到参考物种合并文件: {ref_combined_files[0].name}")
return ref_combined_files[0]
if gene_family:
print(f"🧬 目标基因家族: {gene_family}")
# 从prefix中提取物种前缀和基因家族
species_prefix = prefix.split('.')[0] if '.' in prefix else prefix
if not gene_family and '.' in prefix:
gene_family = prefix.split('.')[1] if len(prefix.split('.')) > 1 else None
patterns = ["*.pep.fasta", "*.pep.fa", "*.protein.fasta", "*.protein.fa","*.fasta", "*.fa", "*.faa"]
# 分类存储
# 使用 set 去重,避免同一文件被多个 pattern 重复匹配
family_specific_refs = set() # 基因家族特定的参考序列 (如 At_NAC.pep.fasta)
full_protein_refs = set() # 全蛋白参考序列 (如 At.pep.fasta)
other_refs = set() # 其他参考序列
# 参考物种关键词映射
ref_species_keywords = ["ath", "at.", "osa", "os.", "gma", "gm.", "bra", "br.", "aly", "al.", "tig", "tg.", "sly", "sl."]
for pattern in patterns:
for file in input_dir.rglob(pattern):
if file.is_file():
filename = file.name.lower()
original_filename = file.name
# 排除用户自己的蛋白质序列文件 (如 Zm.pep.fasta, Zm_NAC.pep.fasta)
if filename.startswith(species_prefix.lower() + ".") or filename.startswith(species_prefix.lower() + "_"):
print(f" 排除用户文件: {original_filename}")
continue
# 排除CDS文件
if filename.endswith(".cds.fasta") or ".cds." in filename:
print(f" 排除CDS文件: {original_filename}")
continue
# 判断是否为基因家族特定的参考序列 (如 At_NAC.pep.fasta)
# 格式: 物种_基因家族.pep.fasta 或 物种基因家族.pep.fasta
is_family_specific = False
if gene_family:
# 检查文件名是否包含基因家族名
gene_family_lower = gene_family.lower()
# 匹配模式: At_NAC, AtNAC, ATH_NAC 等
if f"_{gene_family_lower}." in filename or f"_{gene_family_lower}_" in filename:
is_family_specific = True
elif gene_family_lower in filename.replace("_", "").replace(".", ""):
# 更宽松的匹配: AtNAC, athnac 等
is_family_specific = True
# 判断是否为全蛋白参考序列 (如 At.pep.fasta)
is_full_protein = False
if any(keyword in filename for keyword in ref_species_keywords):
# 检查是否为纯物种名+扩展名 (如 At.pep.fasta, not At_NAC.pep.fasta)
base_name = filename.replace(".pep.fasta", "").replace(".pep.fa", "").replace(".protein.fasta", "").replace(".protein.fa", "").replace(".fa", "").replace(".fasta", "")
if "_" not in base_name and gene_family and gene_family.lower() not in base_name:
is_full_protein = True
if is_family_specific:
print(f" ✅ 找到基因家族特定参考: {original_filename}")
family_specific_refs.add(file)
elif is_full_protein:
print(f" 📄 找到全蛋白参考: {original_filename}")
full_protein_refs.add(file)
else:
other_refs.add(file)
# 对于identify阶段: 优先使用基因家族特定的参考序列
# 将 set 转换为 list 以便索引访问
family_list = sorted(list(family_specific_refs), key=lambda x: x.name)
if family_list:
print(f"\n🎯 找到 {len(family_list)} 个基因家族特定参考序列:")
for i, ref in enumerate(family_list, 1):
print(f" {i}. {ref.name}")
# 如果只有一个,直接返回
if len(family_list) == 1:
print(f"✅ 使用唯一的基因家族特定参考: {family_list[0].name}")
return family_list[0]
else:
# 多个时返回列表(调用方需要处理选择)
print(f"⚠️ 找到多个基因家族特定参考序列,需要用户选择")
return family_list # 返回列表
# 没有找到基因家族特定的参考序列
full_list = sorted(list(full_protein_refs), key=lambda x: x.name)
if full_list:
print(f"\n⚠️ 警告: 未找到基因家族特定参考序列 (如 At_{gene_family}.pep.fasta)")
print(f" 但找到了全蛋白参考序列:")
for ref in full_list:
print(f" - {ref.name}")
print(f"\n❌ 对于identify阶段,需要使用基因家族特定的参考序列")
return None # 返回None,提示用户上传
print("⚠️ 未找到任何参考序列文件")
return None
def _normalize_pfam_query(value: str) -> str:
"""Normalize a Pfam accession/name query for matching Pfam-A.hmm records."""
value = (value or "").strip()
pfam_word_match = re.match(r"^PFAM(\d+)(?:\.\d+)?$", value, flags=re.IGNORECASE)
if pfam_word_match:
return f"PF{pfam_word_match.group(1)}"
if re.match(r"^PF\d+\.\d+$", value, flags=re.IGNORECASE):
return value.split(".", 1)[0].upper()
if re.match(r"^PF\d+$", value, flags=re.IGNORECASE):
return value.upper()
mapped = _pfam_alias_to_accession(value)
if mapped:
return mapped
return value
_PFAM_ALIAS_CACHE: Optional[Dict[str, str]] = None
def _pfam_alias_to_accession(value: str) -> Optional[str]:
"""Resolve common family/domain aliases such as SBP to PFxxxxx accessions."""
global _PFAM_ALIAS_CACHE
query = (value or "").strip()
if not query:
return None
if _PFAM_ALIAS_CACHE is None:
mapping: Dict[str, str] = {
"SPL": "PF03110",
"SBP": "PF03110",
"WRKY": "PF03106",
"NAC": "PF02365",
}
mapping_path = PROJECT_ROOT / "data" / "rag_db" / "pfam_mapping.json"
if mapping_path.exists():
try:
raw_mapping = json.loads(mapping_path.read_text(encoding="utf-8"))
for key, val in raw_mapping.items():
text_val = str(val).strip()
match = re.search(r"\bPF\d{5}(?:\.\d+)?\b", text_val, flags=re.I)
if match:
mapping[str(key).strip().upper()] = _normalize_pfam_accession(match.group(0))
except Exception as exc:
print(f"⚠️ 读取Pfam别名映射失败,使用内置映射: {exc}")
_PFAM_ALIAS_CACHE = mapping
return _PFAM_ALIAS_CACHE.get(query.upper())
def _normalize_pfam_accession(value: str) -> str:
value = (value or "").strip()
pfam_word_match = re.match(r"^PFAM(\d+)(?:\.\d+)?$", value, flags=re.IGNORECASE)
if pfam_word_match:
return f"PF{pfam_word_match.group(1)}"
if re.match(r"^PF\d+\.\d+$", value, flags=re.IGNORECASE):
return value.split(".", 1)[0].upper()
return value.upper()
def _is_pfam_accession(value: str) -> bool:
return bool(re.match(r"^PF\d{5}$", (value or "").strip(), flags=re.IGNORECASE))
def parse_hmm_models(hmm_file: Path) -> List[Dict[str, Any]]:
"""Parse HMMER3 models and return NAME/ACC metadata without loading scores into memory."""
models: List[Dict[str, Any]] = []
current: Optional[Dict[str, Any]] = None
if not hmm_file or not hmm_file.exists():
return models
with open(hmm_file, "r", encoding="latin-1", errors="ignore") as handle:
for raw in handle:
if raw.startswith("HMMER3/"):
if current:
models.append(current)
current = {"name": "", "accession": "", "accession_version": ""}
continue
if current is None:
continue
if raw.startswith("NAME "):
parts = raw.split()
if len(parts) > 1:
current["name"] = parts[1]
elif raw.startswith("ACC "):
parts = raw.split()
if len(parts) > 1:
current["accession_version"] = parts[1]
current["accession"] = _normalize_pfam_accession(parts[1])
elif raw.startswith("//"):
models.append(current)
current = None
if current:
models.append(current)
return models
def hmm_model_audit(hmm_file: Path, requested_pfams: List[str], fetch_map: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Summarize requested vs loaded Pfam models for manifest/status gating."""
requested = [_normalize_pfam_query(q) for q in requested_pfams or [] if str(q).strip()]
requested_unique = list(dict.fromkeys(requested))
models = parse_hmm_models(hmm_file)
loaded_accessions = sorted({m.get("accession") for m in models if m.get("accession")})
query_name_to_accession = {
str(m.get("name")): str(m.get("accession"))
for m in models
if m.get("name") and m.get("accession")
}
requested_accessions = [q for q in requested_unique if _is_pfam_accession(q)]
missing = sorted(set(requested_accessions) - set(loaded_accessions))
return {
"status": "failed" if missing else "success",
"query_hmm": str(hmm_file.resolve()) if hmm_file and hmm_file.exists() else str(hmm_file or ""),
"requested_pfams": requested_unique,
"loaded_accessions": loaded_accessions,
"missing_requested_pfams": missing,
"query_name_to_accession": query_name_to_accession,
"models": models,
"fetch_map": fetch_map or {},
"model_count": len(models),
}
def assert_hmm_models_complete(hmm_file: Path, requested_pfams: List[str], fetch_map: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
audit = hmm_model_audit(hmm_file, requested_pfams, fetch_map)
missing = audit.get("missing_requested_pfams") or []
if missing:
raise RuntimeError(
"Requested Pfam model(s) missing from query HMM: "
+ ", ".join(missing)
+ f"; query_hmm={hmm_file}"
)
return audit
def _split_pfam_queries(pfams: str) -> List[str]:
queries: List[str] = []
seen: Set[str] = set()
for item in str(pfams).split(","):
query = _normalize_pfam_query(item)
if query and query not in seen:
seen.add(query)
queries.append(query)
return queries
def _resolve_pfam_db(pfam_dir_arg: Optional[str] = None) -> Optional[Path]:
"""Find Pfam-A.hmm using CLI/env hints and conservative local defaults."""
candidates: List[Path] = []
for raw in [pfam_dir_arg, os.environ.get("BBA_PFAM_DIR"), os.environ.get("PFAM_DIR")]:
if not raw:
continue
p = Path(raw).expanduser()
candidates.extend([p / "Pfam-A.hmm", p / "Pfam-A.hmm.gz", p])
candidates.extend([
PROJECT_ROOT / "data" / "hmm" / "Pfam-A.hmm",
PROJECT_ROOT / "data" / "hmm" / "Pfam-A.hmm.gz",
Path("data/hmm/Pfam-A.hmm"),
Path("data/hmm/Pfam-A.hmm.gz"),
Path("data/pfam/Pfam-A.hmm"),
Path("data/pfam/Pfam-A.hmm.gz"),
Path("config/Pfam-A.hmm"),
Path("config/Pfam-A.hmm.gz"),
PROJECT_ROOT / "assets" / "hmm" / "Pfam-A.hmm",
PROJECT_ROOT / "assets" / "hmm" / "Pfam-A.hmm.gz",
])
for candidate in candidates:
candidate = candidate.resolve() if not candidate.is_absolute() else candidate
if candidate.is_file():
return candidate
return None
def _decompress_pfam_db_if_needed(pfam_db: Path, cache_dir: Path) -> Path:
if pfam_db.suffix != ".gz":
return pfam_db
cache_dir.mkdir(parents=True, exist_ok=True)
out_path = cache_dir / pfam_db.stem
if out_path.exists() and out_path.stat().st_mtime >= pfam_db.stat().st_mtime:
return out_path
print(f"Decompressing Pfam DB: {pfam_db} -> {out_path}")
with gzip.open(pfam_db, "rb") as src, open(out_path, "wb") as dst:
shutil.copyfileobj(src, dst)
return out_path
def _is_valid_hmm_file(path: Path) -> bool:
if not path.exists() or path.stat().st_size <= 0:
return False
try:
with open(path, "r", encoding="latin-1") as handle:
return handle.readline().startswith("HMMER3/")
except Exception:
return False
def _pfam_hmm_matches_query(path: Path, query: str) -> bool:
query_norm = _normalize_pfam_query(query)
query_upper = query_norm.upper()
if not _is_valid_hmm_file(path):
return False
try:
for model in parse_hmm_models(path):
model_acc = str(model.get("accession") or "").upper()
model_name = str(model.get("name") or "").upper()
if _is_pfam_accession(query_norm):
if model_acc == query_upper:
return True
elif model_name == query_upper:
return True
except Exception:
return False
return False
def _local_hmm_search_roots(cache_dir: Path) -> List[Path]:
roots = [
cache_dir,
PROJECT_ROOT / "assets" / "hmm",
PROJECT_ROOT / "assets" / "examples" / "test_input",
PROJECT_ROOT / "data" / "pfam",
PROJECT_ROOT / "data" / "hmm",
PROJECT_ROOT / "input",
PROJECT_ROOT / "workspace",
]
seen: Set[str] = set()
existing: List[Path] = []
for root in roots:
try:
resolved = root.resolve()
except Exception:
continue
key = str(resolved)
if key in seen or not resolved.exists():
continue
seen.add(key)
existing.append(resolved)
return existing
def _find_cached_pfam_hmm(query: str, cache_dir: Path) -> Optional[Path]:
"""Find a valid local single-model HMM by accession or NAME before using network."""
query_norm = _normalize_pfam_query(query)
candidate_names = [
f"{query_norm}.hmm",
f"{query_norm.upper()}.hmm",
f"{query}.hmm",
]
for root in _local_hmm_search_roots(cache_dir):
for name in dict.fromkeys(candidate_names):
candidate = root / name
if _pfam_hmm_matches_query(candidate, query_norm):
return candidate
try:
hmm_files = root.rglob("*.hmm")
except Exception:
continue
for candidate in hmm_files:
if candidate.name == "Pfam-A.hmm":
continue
if _pfam_hmm_matches_query(candidate, query_norm):
return candidate
return None
def _copy_cached_hmm_to_query_cache(source: Path, query: str, cache_dir: Path) -> Path:
query_norm = _normalize_pfam_query(query)
dest = cache_dir / f"{query_norm}.hmm"
cache_dir.mkdir(parents=True, exist_ok=True)
if source.resolve() != dest.resolve():
shutil.copy2(source, dest)
return dest
def _download_single_pfam_hmm(query: str, cache_dir: Path) -> Path:
"""Download one Pfam HMM from the official InterPro/Pfam endpoint."""
import urllib.request
import urllib.error
query = _normalize_pfam_query(query)
gz_path = cache_dir / f"{query}.hmm.gz"
hmm_path = cache_dir / f"{query}.hmm"
if _is_valid_hmm_file(hmm_path):
return hmm_path
cached = _find_cached_pfam_hmm(query, cache_dir)
if cached:
print(f"Using cached Pfam HMM for {query}: {cached}")
return _copy_cached_hmm_to_query_cache(cached, query, cache_dir)
url = f"https://www.ebi.ac.uk/interpro/wwwapi/entry/pfam/{query}?annotation=hmm"
print(f"Downloading Pfam HMM from InterPro: {query} -> {hmm_path}")
cache_dir.mkdir(parents=True, exist_ok=True)
try:
with urllib.request.urlopen(url, timeout=60) as response:
data = response.read()
except urllib.error.HTTPError as exc:
cached = _find_cached_pfam_hmm(query, cache_dir)
if cached:
print(f"InterPro returned HTTP {exc.code} for {query}; using cached HMM: {cached}")
return _copy_cached_hmm_to_query_cache(cached, query, cache_dir)
raise RuntimeError(
f"Pfam HMM download failed for {query}: HTTP {exc.code}. "
"Please provide Pfam-A.hmm, upload a .hmm file, or add the family/Pfam mapping."
) from exc
except Exception as exc:
cached = _find_cached_pfam_hmm(query, cache_dir)
if cached:
print(f"InterPro download failed for {query}; using cached HMM: {cached}")
return _copy_cached_hmm_to_query_cache(cached, query, cache_dir)
raise RuntimeError(
f"Pfam HMM download failed for {query}: {exc}. "
"Please provide Pfam-A.hmm, upload a .hmm file, or add the family/Pfam mapping."
) from exc
gz_path.write_bytes(data)
try:
text = gzip.decompress(data).decode("latin-1")
except OSError:
text = data.decode("latin-1")
if not text.startswith("HMMER3/"):
raise RuntimeError(f"Downloaded Pfam HMM for {query} is not a HMMER profile")
hmm_path.write_text(text, encoding="latin-1")
return hmm_path
def download_required_pfam_hmms(required_pfams: str, cache_dir: Path) -> Tuple[Path, List[str], Dict[str, str]]:
"""Build a query HMM by downloading individual Pfam models."""
queries = _split_pfam_queries(required_pfams)
if not queries:
raise RuntimeError("--required-pfams was provided but no valid Pfam IDs/names were parsed")
output = cache_dir / f"{'_'.join(queries)}.hmm"
fetch_map: Dict[str, str] = {}
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="latin-1") as out:
for query in queries:
hmm_path = _download_single_pfam_hmm(query, cache_dir)
text = hmm_path.read_text(encoding="latin-1")
name = query
for line in text.splitlines():
if line.startswith("NAME "):
parts = line.split()
if len(parts) > 1:
name = parts[1]
break
out.write(text)
if not text.endswith("\n"):
out.write("\n")
fetch_map[query] = name
assert_hmm_models_complete(output, queries, fetch_map)
return output, queries, fetch_map
def hmmfetch_models(pfam_db_unz: Path, pfams: str, out_hmm: Path) -> Dict[str, str]:
"""Fetch one or more Pfam HMM models into a merged query HMM.
HMMER hmmfetch does not accept comma-joined keys. Fetch models one by one,
then validate the merged ACC list; if indexed lookup cannot resolve an
accession/name, fall back to strict Python parsing.
"""
print(f"Fetching {pfams} from {pfam_db_unz}")
queries = _split_pfam_queries(pfams)
if not queries:
raise RuntimeError("No Pfam query was provided")
out_hmm.parent.mkdir(parents=True, exist_ok=True)
tmp_dir = out_hmm.parent / f"{out_hmm.stem}.hmmfetch_tmp"
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True, exist_ok=True)
fetch_map: Dict[str, str] = {}
parts: List[Path] = []
failed: List[str] = []
for query in queries:
part = tmp_dir / f"{query}.hmm"
cmd = [str(hmmfetch_bin()), '-o', str(part), str(pfam_db_unz), query]
rc, out, err = run_cmd(cmd)
if rc == 0 and part.exists() and part.stat().st_size > 0:
audit = hmm_model_audit(part, [query])
if not audit.get("missing_requested_pfams"):
parts.append(part)
model = (audit.get("models") or [{}])[0]
fetch_map[query] = str(model.get("name") or query)
continue
failed.append(query)
if failed:
print(f"hmmfetch could not resolve {', '.join(failed)}, trying Python ACC/NAME fallback...")
shutil.rmtree(tmp_dir, ignore_errors=True)
return python_fetch_models(pfam_db_unz, ",".join(queries), out_hmm)
with open(out_hmm, 'w', encoding='latin-1') as fout:
for part in parts:
text = part.read_text(encoding='latin-1')
fout.write(text)
if not text.endswith("\n"):
fout.write("\n")
shutil.rmtree(tmp_dir, ignore_errors=True)
assert_hmm_models_complete(out_hmm, queries, fetch_map)
return fetch_map
def python_fetch_models(pfam_db: Path, pfams: str, out_hmm: Path) -> Dict[str, str]:
"""
手动从 Pfam HMM 文件中提取指定的模型。
"""
print(f"Python fetching {pfams} from {pfam_db}")
pfam_list = _split_pfam_queries(pfams)
requested = set(pfam_list)
found: Dict[str, str] = {}
out_lines = []
def flush_model(model_lines: List[str]) -> None:
if not model_lines:
return
name = ""
accessions: Set[str] = set()
for model_line in model_lines:
if model_line.startswith("NAME "):
parts = model_line.split()
if len(parts) > 1:
name = parts[1]
elif model_line.startswith("ACC "):
parts = model_line.split()
if len(parts) > 1:
acc = _normalize_pfam_query(parts[1])
accessions.add(acc)
name_key = name.upper() if name else ""
matched = []
for q in sorted(requested - set(found)):
q_norm = _normalize_pfam_query(q)
if _is_pfam_accession(q_norm):
if q_norm in accessions:
matched.append(q)
elif name_key and q_norm.upper() == name_key:
matched.append(q)
if matched:
out_lines.extend(model_lines)
if not model_lines[-1].endswith("\n"):
out_lines.append("\n")
for q in matched:
found[q] = name or q
print(f" ✓ {', '.join(matched)} -> {name or matched[0]}")
model: List[str] = []
with open(pfam_db, 'r', encoding='latin-1') as fin:
for line in fin:
if line.startswith('HMMER3/'):
flush_model(model)
model = [line]
elif model:
model.append(line)
flush_model(model)
missing = requested - set(found)
if missing:
raise RuntimeError(f"Python fetch models failed, missing: {', '.join(missing)} from {pfam_db}")
out_hmm.parent.mkdir(parents=True, exist_ok=True)
with open(out_hmm, 'w', encoding='latin-1') as fout:
fout.writelines(out_lines)
audit = assert_hmm_models_complete(out_hmm, pfam_list, found)
print(f"Saved {len(found)} requested model(s) to {out_hmm}; loaded ACCs: {', '.join(audit.get('loaded_accessions') or [])}")
return found
def build_required_pfam_hmm(required_pfams: str, pfam_dir: Optional[str], cache_dir: Path) -> Tuple[Path, List[str], Dict[str, str]]:
"""Build a query HMM from Pfam-A.hmm for --required-pfams."""
queries = _split_pfam_queries(required_pfams)
if not queries:
raise RuntimeError("--required-pfams was provided but no valid Pfam IDs/names were parsed")
pfam_db = _resolve_pfam_db(pfam_dir)
if not pfam_db:
print("未找到本地 Pfam-A.hmm,改用官方 InterPro/Pfam 单模型下载。")
return download_required_pfam_hmms(required_pfams, cache_dir)
pfam_db_unz = _decompress_pfam_db_if_needed(pfam_db, cache_dir)
out_hmm = cache_dir / f"{'_'.join(queries)}.hmm"
fetch_map = hmmfetch_models(pfam_db_unz, ",".join(queries), out_hmm)
assert_hmm_models_complete(out_hmm, queries, fetch_map)
return out_hmm, queries, fetch_map
def hmmsearch_bin() -> Path:
"""返回 hmmsearch 二进制路径"""
import shutil as _shutil
for p in [
'/home/xzq/miniconda3/envs/test4py311/bin/hmmsearch',
'hmmsearch',
]:
candidate = Path(p)
if candidate.is_absolute() and candidate.exists():
return candidate
resolved = _shutil.which(p)
if resolved:
return Path(resolved)
raise RuntimeError("hmmsearch not found in PATH")
def hmmfetch_bin() -> Path:
"""返回 hmmfetch 二进制路径"""
import shutil as _shutil
for p in [
'/home/xzq/miniconda3/envs/test4py311/bin/hmmfetch',
'hmmfetch',
]:
candidate = Path(p)
if candidate.is_absolute() and candidate.exists():
return candidate
resolved = _shutil.which(p)
if resolved:
return Path(resolved)
raise RuntimeError("hmmfetch not found in PATH")
def read_pep_ids(pep_fasta: Path) -> List[str]:
"""读取蛋白质fasta中的所有ID"""
ids = []
with open(pep_fasta, 'r') as f:
for line in f:
if line.startswith('>'):
# 去除 > 和可能的注释
seq_id = line[1:].split()[0].strip()
ids.append(seq_id)
return ids
def read_ids_from_file(ids_file: Path) -> Set[str]:
"""读取ID文件,返回集合"""
ids = set()
if ids_file and ids_file.exists():
with open(ids_file, 'r') as f:
for line in f:
id_str = line.strip()
if id_str:
ids.add(id_str)
return ids
def summarize_domtblout_by_model(
domtblout: Path,
evalue_max: float,
query_name_to_accession: Optional[Dict[str, str]] = None,
) -> Dict[str, int]:
"""Count unique target IDs per HMM query model in a domtblout file."""
counts: Dict[str, Set[str]] = {}
mapping = query_name_to_accession or {}
if not domtblout.exists():
return {}
with open(domtblout, 'r', encoding='utf-8', errors='ignore') as handle:
for line in handle:
if line.startswith('#') or not line.strip():
continue
parts = line.strip().split()
if len(parts) < 13:
continue
try:
evalue = float(parts[12])
except Exception:
continue
if evalue > evalue_max:
continue
target = parts[0]
query = parts[3]
key = mapping.get(query) or query
counts.setdefault(key, set()).add(target)
return {key: len(value) for key, value in sorted(counts.items())}
def parse_domtblout(domtblout: Path, evalue_max: float, cov_min: float, required_pfams: List[str], query_name_to_accession: Optional[Dict[str, str]] = None) -> Tuple[dict, dict]:
"""
解析 hmmsearch 的 domtblout 文件。
返回:
- per_seq: {seq_id: {pfam: [(evalue, cov)]}}
- per_seq_best: {seq_id: {pfam: (evalue, cov)}}
"""
per_seq = {}
per_seq_best = {}
# FIX: 防御性处理 - 如果 required_pfams 为 None,设为空集合(不过滤)
required_set = set(required_pfams) if required_pfams is not None else set()
with open(domtblout, 'r') as f:
for line in f:
if line.startswith('#'):
continue
parts = line.strip().split()
if len(parts) < 23:
continue
target = parts[0] # 序列ID
query_name = parts[3] # HMM名称 (NAME in query HMM)
query = (query_name_to_accession or {}).get(query_name, query_name)
if required_set and query not in required_set:
continue # 只关注我们需要的Pfam(如果设置了过滤集合)
evalue = float(parts[12]) # domain i-Evalue
# 计算覆盖度
alen = int(parts[2]) # 序列长度
hmm_from = int(parts[15])
hmm_to = int(parts[16])
hmm_cov = (hmm_to - hmm_from + 1) / alen if alen > 0 else 0
# 初步过滤
if evalue > evalue_max or hmm_cov < cov_min:
continue
# 记录到 per_seq
if target not in per_seq:
per_seq[target] = {}
per_seq_best[target] = {}
if query not in per_seq[target]:
per_seq[target][query] = []
per_seq_best[target][query] = (evalue, hmm_cov)
per_seq[target][query].append((evalue, hmm_cov))
# 更新最佳值(E-value最小)
if evalue < per_seq_best[target][query][0]:
per_seq_best[target][query] = (evalue, hmm_cov)
return per_seq, per_seq_best
# ======== 从这里开始插入 ========
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 as e:
return f"error: {e}"
# ======== 插入结束 ========
# 添加工具版本打印信息
def get_tool_versions() -> dict:
"""获取关键工具版本信息"""
versions = {}
# 1. HMMER
try:
# result = subprocess.run(["hmmsearch", "-h"], shell=Ture, apture_output=True, text=True, timeout=5)
# for line in result.stderr.split('\n'):
# if "HMMER" in line:
# versions["hmmer"] = line.strip()
# break
# except:
# versions["hmmer"] = "unknown"
# 先检查命令是否存在
import shutil
if not shutil.which("hmmsearch"):
versions["hmmer"] = "command not found in PATH"
else:
# 使用更全面的参数尝试
for arg in ["-h", "--help", "-version", "--version"]:
try:
result = subprocess.run(
["hmmsearch", arg],
capture_output=True,
text=True,
timeout=5,
encoding='utf-8',
errors='ignore'
)
# 合并所有输出
all_output = result.stdout + result.stderr
# 使用正则表达式搜索版本
import re
version_match = re.search(r'HMMER\s+(\d+\.\d+\.?\d*)', all_output)
if version_match:
versions["hmmer"] = f"HMMER {version_match.group(1)}"
break
# 或者搜索包含 HMMER 的行
for line in all_output.split('\n'):
if "HMMER" in line.upper():
versions["hmmer"] = line.strip()
break
if "hmmer" in versions:
break
except Exception:
continue
if "hmmer" not in versions:
versions["hmmer"] = "version detection failed"
except Exception as e:
versions["hmmer"] = f"error: {str(e)[:50]}"
# 2. BLAST
try:
result = subprocess.run(["blastp", "-version"], capture_output=True, text=True, timeout=5)
for line in result.stdout.split('\n'):
if "blastp" in line:
versions["blast"] = line.strip()
break
except:
versions["blast"] = "unknown"
# 3. Python 版本
versions["python"] = sys.version.split()[0]
return versions
def select_final_members(
pep_ids: List[str],
per_seq: dict,
per_seq_best: dict,
required_pfams: List[str],
evalue_max: float,
cov_min: float
) -> Tuple[List[str], dict]:
"""
选择最终的成员。
严格模式:必须包含所有 required_pfams。
返回 (final_ids, drop_reasons)
"""
final = []
drop_reasons = {}
required_set = set(required_pfams)
for seq_id in pep_ids:
if seq_id not in per_seq:
drop_reasons[seq_id] = 'no_hit'
continue
# 检查是否包含所有必需的 Pfam
hits = set(per_seq[seq_id].keys())
missing = required_set - hits
if missing:
drop_reasons[seq_id] = f'missing_required'
continue
# 检查每个Pfam是否通过阈值
passed_all = True
for pfam in required_set:
best_e, best_cov = per_seq_best[seq_id][pfam]
if best_e > evalue_max or best_cov < cov_min:
passed_all = False
drop_reasons[seq_id] = f'failed_qc_{pfam}'
break
if passed_all:
final.append(seq_id)
else:
# 如果已经在上面记录了原因,这里不再记录
if seq_id not in drop_reasons:
drop_reasons[seq_id] = 'failed_qc'
return final, drop_reasons
def write_fasta_subset(source_fasta: Path, ids: List[str], out_fasta: Path):
"""从source_fasta中提取指定的IDs写入out_fasta"""
id_set = set(ids)
writing = False
current_id = None
out_lines = []
with open(source_fasta, 'r') as f:
for line in f:
if line.startswith('>'):
# 提取ID
header = line[1:].strip()
seq_id = header.split()[0]
if seq_id in id_set:
writing = True
current_id = seq_id
out_lines.append(line)
else:
writing = False
current_id = None
elif writing:
out_lines.append(line)
out_fasta.parent.mkdir(parents=True, exist_ok=True)
with open(out_fasta, 'w') as f:
f.writelines(out_lines)
print(f"Saved {len(ids)} sequences to {out_fasta}")
def write_report(
out_report: Path,
prefix: str,
required_pfams: List[str],
evalue_max: float,
cov_min: float,
cut_ga: bool,
cpu: int,
pfam_db: Path,
retention_days: int,
total_proteins: int,
candidates: int,
final_members: int,
drop_reasons: dict,
start_time: datetime,
# ====== 新增参数 ======
tool_versions: dict = None,
cmd: List[str] = None,
inputs: List[dict] = None,
end_time: datetime = None,
elapsed_sec: float = None
):
"""写入鉴定报告"""
# 设置默认值
tool_versions = tool_versions or {}
cmd = cmd or []
inputs = inputs or []
lines = [
f"# Identify Report: {prefix}",
"",
"## Parameters",
f"- required_pfams: {', '.join(required_pfams)}",
f"- dom_i_evalue_max: {evalue_max}",
f"- hmm_cov_min: {cov_min}",
f"- cut_ga: {cut_ga}",
f"- cpu: {cpu}",
f"- pfam_db: {pfam_db}",
f"- retention_days: {retention_days} (expires around {start_time + timedelta(days=retention_days)})",
"",
"## Summary",
f"- total input proteins: {total_proteins}",
f"- candidates (any required Pfam hit passing QC): {candidates}",
f"- strict final members (contain ALL required Pfams): {final_members}",
]
# 统计 drop 原因
reason_counts = {}
for reason in drop_reasons.values():
reason_counts[reason] = reason_counts.get(reason, 0) + 1
for reason, cnt in reason_counts.items():
lines.append(f"- dropped {reason}: {cnt}")
# ====== 新增:Reproducibility Section ======
lines.append("")
lines.append("## Reproducibility")
lines.append("")
# 工具版本
if tool_versions:
lines.append("### Tool Versions")
for tool, version in sorted(tool_versions.items()):
lines.append(f"- **{tool}**: `{version}`")
lines.append("")
# 执行时间
if elapsed_sec is not None:
lines.append(f"- **Elapsed time**: {elapsed_sec:.2f} seconds")
if start_time:
lines.append(f"- **Start time**: {start_time.isoformat()}")
if end_time:
lines.append(f"- **End time**: {end_time.isoformat()}")
lines.append("")
# 执行命令
if cmd:
lines.append("### Command")
lines.append("```bash")
# 美化命令:每行显示一部分,避免过长
cmd_str = " ".join(cmd)
if len(cmd_str) > 80:
# 简单换行:每80个字符换行
import textwrap
wrapped = textwrap.wrap(cmd_str, width=80)
lines.extend(wrapped)
else:
lines.append(cmd_str)
lines.append("```")
lines.append("")
# 输入文件摘要
if inputs:
lines.append("### Input Files")
lines.append("| File | Size (MB) | SHA256 (first 12) |")
lines.append("|------|-----------|-------------------|")
for inp in inputs:
name = inp.get("path", "unknown")
size_bytes = inp.get("size", 0)
size_mb = size_bytes / (1024 * 1024) if size_bytes > 0 else 0
sha_full = inp.get("sha256", "")
sha_short = sha_full[:12] if sha_full else "N/A"
lines.append(f"| `{name}` | {size_mb:.2f} | `{sha_short}` |")
lines.append("")
# ====== 新增部分结束 ======
lines.append("")
lines.append("## Dropped IDs (auto explanation)")
lines.append("")
# 列出前20个被drop的ID
dropped_items = list(drop_reasons.items())
for seq_id, reason in dropped_items[:20]:
lines.append(f"- {seq_id:<25} REASON={reason}")
if len(dropped_items) > 20:
lines.append(f"- ... and {len(dropped_items) - 20} more")
lines.append("")
lines.append(f"Report generated at {datetime.now().isoformat()}")
out_report.parent.mkdir(parents=True, exist_ok=True)
with open(out_report, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
print(f"Report: {out_report}")
def run_hmm_analysis(
input_dir: Path,
output_dir: Path,
prefix: str,
hmm_file: Path,
pep_file: Path,
cpu: int = 8,
evalue: float = 1e-5 # 默认E值阈值
) -> Tuple[Path, Path]:
"""
运行HMM分析(使用原始蛋白质序列文件)
返回: (domtblout_file, ids_file)
"""
print("\n" + "=" * 60)
print("🔎 运行HMM分析(使用原始文件)")
print("=" * 60)
# 创建分析目录
analysis_dir = output_dir / "analysis"
analysis_dir.mkdir(parents=True, exist_ok=True)
# 定义输出文件
domtblout_file = analysis_dir / f"{prefix}.domtblout"
hmmout_file = analysis_dir / f"{prefix}.hmmout"
filtered_file = analysis_dir / f"{prefix}.domtblout.filter"
ids_file = analysis_dir / f"{prefix}.hmm.ids"
hmm_safe_pep = analysis_dir / f"{prefix}.hmmsearch_safe.pep.fa"
sanitize_stats = prepare_fasta_for_hmmsearch(pep_file, hmm_safe_pep)
if sanitize_stats["skipped_long_sequences"] or sanitize_stats["removed_invalid_residues"]:
print(
"🧹 HMMER输入清洗: "
f"原始序列 {sanitize_stats['sequence_count']} 条,"
f"写入 {sanitize_stats['written_sequences']} 条,"
f"跳过超长序列 {sanitize_stats['skipped_long_sequences']} 条,"
f"移除非法字符 {sanitize_stats['removed_invalid_residues']} 个。"
)
print(f" 清洗报告: {sanitize_stats['report']}")
if sanitize_stats["written_sequences"] == 0:
raise RuntimeError(
f"hmmsearch无法执行:{pep_file.name} 中没有可用蛋白序列。"
f"可能是文件不是蛋白FASTA,或所有序列长度超过 {sanitize_stats['max_length']}。"
)
# 1. 运行hmmsearch
print(f"\n🚀 执行hmmsearch命令:")
cmd = [
str(hmmsearch_bin()),
'-E', str(evalue), # 使用传入的E值阈值
'--domtblout', str(domtblout_file),
'-o', str(hmmout_file),
'--cpu', str(cpu),
str(hmm_file),
str(hmm_safe_pep)
]
print(f" {' '.join(cmd)}")
rc, out, err = run_cmd(cmd)
if rc != 0:
raise RuntimeError(f"hmmsearch失败: {err}")
print(f"✅ hmmsearch完成: {domtblout_file}")
# 2. 过滤 domain i-Evalue
print(f"\n🚀 过滤结果 (domain i-Evalue < {evalue}):")
awk_cmd = f"awk '$13<{evalue} && $1 !~ /^#/ {{print $0 }}' {domtblout_file} > {filtered_file}"
print(f" {awk_cmd}")
rc, out, err = run_cmd(awk_cmd, shell=True)
if rc != 0:
raise RuntimeError(f"过滤失败: {err}")
print(f"✅ 过滤完成: {filtered_file}")
# 3. 提取基因ID
print(f"\n🚀 提取基因ID:")
id_cmd = f"awk '{{print $1}}' {filtered_file} | sort -u > {ids_file}"
print(f" {id_cmd}")
rc, out, err = run_cmd(id_cmd, shell=True)
if rc != 0:
raise RuntimeError(f"提取ID失败: {err}")
print(f"✅ 提取ID完成: {ids_file}")
# 显示结果
with open(ids_file, 'r') as f:
gene_ids = [line.strip() for line in f if line.strip()]
print(f"\n📊 HMM分析结果:")
print(f" 鉴定到的基因数: {len(gene_ids)}")
if gene_ids:
print(f"\n🔝 前10个基因:")
for i, gene_id in enumerate(gene_ids[:10]):
print(f" {i+1:2d}. {gene_id}")
if len(gene_ids) > 10:
print(f" ... 还有 {len(gene_ids)-10} 个基因")
return domtblout_file, ids_file
def cleanup_current_blast_intermediates(analysis_dir: Path, prefix: str) -> None:
"""Remove current-prefix BLAST outputs and legacy BLAST DB files before rerun."""
analysis_dir.mkdir(parents=True, exist_ok=True)
patterns = [f"{prefix}.blast*", "blast_db.*"]
removed = 0
for pattern in patterns:
for path in analysis_dir.glob(pattern):
try:
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
removed += 1
except FileNotFoundError:
continue
except Exception as exc:
print(f"⚠️ 无法清理旧BLAST中间文件 {path}: {exc}")
if removed:
print(f"🧹 已清理当前prefix旧BLAST中间文件/数据库: {removed} 个")
def build_target_proteome_blast_db(analysis_dir: Path, pep_file: Path) -> Dict[str, Any]:
"""Build the fixed identify BLAST database from the target whole proteome."""
analysis_dir.mkdir(parents=True, exist_ok=True)
db_prefix = analysis_dir / "blast_db.target_proteome"
print(f"\n🚀 创建目标全蛋白Blast数据库:")
cmd = ['makeblastdb', '-in', str(pep_file), '-dbtype', 'prot', '-out', str(db_prefix)]
print(f" {' '.join(cmd)}")
rc, out, err = run_cmd(cmd)
if rc != 0:
print(f"⚠️ 创建目标全蛋白数据库失败: {err}")
return {
"status": "failed",
"target_database_input": str(pep_file.resolve()),
"target_database_role": "target_proteome",
"target_db_prefix": str(db_prefix.resolve()),
"error": err,
}
database_files = [
str(path.resolve()) for path in sorted(db_prefix.parent.glob(db_prefix.name + ".*"))
]
print("✅ 目标全蛋白数据库创建完成")
return {
"status": "success",
"target_database_input": str(pep_file.resolve()),
"target_database_role": "target_proteome",
"target_db_prefix": str(db_prefix.resolve()),
"target_database_files": database_files,
}
def parse_blast_outfmt6_target_hits(
blast_output: Path,
evalue: float = 1e-7,
identity_min: float = 30.0,
) -> Tuple[Set[str], Dict[str, Dict[str, Any]]]:
"""Parse identify BLAST outfmt6 and return target candidate IDs from sseqid."""
kept_ids: Set[str] = set()
support: Dict[str, Dict[str, Any]] = {}
with Path(blast_output).open("r", encoding="utf-8", errors="ignore") as handle:
for raw in handle:
parts = raw.rstrip("\n").split("\t")
if len(parts) < 12:
continue
query_id = parts[0].strip()
target_id = parts[1].strip()
if not query_id or not target_id:
continue
try:
pident = float(parts[2])
hit_evalue = float(parts[10])
bitscore = float(parts[11])
except ValueError:
continue
if pident <= identity_min or hit_evalue > evalue:
continue
kept_ids.add(target_id)
entry = support.setdefault(
target_id,
{"query_ids": set(), "best_evalue": hit_evalue, "best_bitscore": bitscore},
)
entry["query_ids"].add(query_id)
if hit_evalue < entry["best_evalue"] or (
hit_evalue == entry["best_evalue"] and bitscore > entry["best_bitscore"]
):
entry["best_evalue"] = hit_evalue
entry["best_bitscore"] = bitscore
evidence_by_gene: Dict[str, Dict[str, Any]] = {}
for target_id, entry in support.items():
evidence_by_gene[target_id] = {
"query_ids": sorted(entry["query_ids"]),
"best_evalue": entry["best_evalue"],
"best_bitscore": entry["best_bitscore"],
}
return kept_ids, evidence_by_gene
def run_blast_analysis(
input_dir: Path,
output_dir: Path,
prefix: str,
ref_file: Path,
pep_file: Path,
cpu: int = 8,
evalue: float = 1e-7,
identity_min: float = 30.0,
reference_label: str = "reference",
target_db_info: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Run one fixed-direction identify BLAST search.
The target species whole-proteome FASTA is the BLAST database, the reference
family protein FASTA is the query, and candidate target IDs are parsed from
outfmt6 sseqid.
"""
print("\n" + "=" * 60)
print(f"🧬 运行Blast分析(参考集合: {reference_label})")
print("=" * 60)
if not ref_file or not ref_file.exists():
print("⚠️ 未提供参考序列文件,跳过Blast分析")
return None
analysis_dir = output_dir / "analysis"
analysis_dir.mkdir(parents=True, exist_ok=True)
if target_db_info is None:
target_db_info = build_target_proteome_blast_db(analysis_dir, pep_file)
if target_db_info.get("status") != "success":
return {
"status": "failed",
"reference_label": sanitize_token(reference_label or infer_reference_label(ref_file)),
"reference_file": str(ref_file.resolve()),
"query_file": str(ref_file.resolve()),
"query_role": "reference_family_pep",
"target_database_input": str(pep_file.resolve()),
"target_database_role": "target_proteome",
"target_db_prefix": target_db_info.get("target_db_prefix", ""),
"target_database_files": target_db_info.get("target_database_files", []),
"candidate_id_column": "sseqid",
"error": target_db_info.get("error", "target proteome BLAST database build failed"),
}
ref_token = sanitize_token(reference_label or infer_reference_label(ref_file))
db_prefix = Path(str(target_db_info["target_db_prefix"]))
blast_output = analysis_dir / f"{prefix}.blast.{ref_token}.out"
ids_file = analysis_dir / f"{prefix}.blast.{ref_token}.ids"
print(f"\n🚀 运行blastp:")
cmd = [
'blastp',
'-query', str(ref_file),
'-db', str(db_prefix),
'-out', str(blast_output),
'-evalue', str(evalue),
'-outfmt', '6 std qlen slen',
'-num_threads', str(cpu),
]
print(f" {' '.join(cmd)}")
rc, out, err = run_cmd(cmd)
if rc != 0:
print(f"⚠️ blastp失败: {err}")
return {
"status": "failed",
"reference_label": ref_token,
"reference_file": str(ref_file.resolve()),
"reference_file_name": ref_file.name,
"query_file": str(ref_file.resolve()),
"query_role": "reference_family_pep",
"target_database_input": str(pep_file.resolve()),
"target_database_role": "target_proteome",
"target_db_prefix": str(db_prefix.resolve()),
"target_database_files": target_db_info.get("target_database_files", []),
"candidate_id_column": "sseqid",
"blast_output": str(blast_output.resolve()),
"error": err,
}
print(f"✅ blastp完成: {blast_output}")
kept_ids, evidence_by_gene = parse_blast_outfmt6_target_hits(blast_output, evalue=evalue, identity_min=identity_min)
write_id_list(sorted(kept_ids), ids_file)
print(f"✅ 过滤完成: {ids_file}")
print(f"\n📊 Blast分析结果 ({ref_token}):")
print(f" 鉴定到的目标基因数: {len(kept_ids)}")
for i, gene_id in enumerate(sorted(kept_ids)[:10], 1):
print(f" {i:2d}. {gene_id}")
if len(kept_ids) > 10:
print(f" ... 还有 {len(kept_ids)-10} 个基因")
return {
"status": "success",
"reference_label": ref_token,
"reference_file": str(ref_file.resolve()),
"reference_file_name": ref_file.name,
"reference_records": count_fasta_records(ref_file),
"query_file": str(ref_file.resolve()),
"query_role": "reference_family_pep",
"target_database_input": str(pep_file.resolve()),
"target_database_role": "target_proteome",
"target_db_prefix": str(db_prefix.resolve()),
"target_database_files": target_db_info.get("target_database_files", []),
"candidate_id_column": "sseqid",
"blast_output": str(blast_output.resolve()),
"blast_identity_min": identity_min,
"ids_file": str(ids_file.resolve()),
"members": len(kept_ids),
"ids": sorted(kept_ids),
"evidence_by_gene": evidence_by_gene,
}
def run_multi_reference_blast_analysis(
input_dir: Path,
output_dir: Path,
prefix: str,
references: List[Dict[str, Any]],
pep_file: Path,
cpu: int = 8,
evalue: float = 1e-7,
identity_min: float = 30.0,
) -> Dict[str, Any]:
"""Run each reference family query against one target-proteome BLAST DB."""
successful: List[Dict[str, Any]] = []
failed: List[Dict[str, Any]] = []
source_by_gene: Dict[str, Set[str]] = {}
query_by_gene: Dict[str, Set[str]] = {}
best_by_gene: Dict[str, Dict[str, float]] = {}
analysis_dir = output_dir / "analysis"
analysis_dir.mkdir(parents=True, exist_ok=True)
cleanup_current_blast_intermediates(analysis_dir, prefix)
target_db_info = build_target_proteome_blast_db(analysis_dir, pep_file)
if target_db_info.get("status") != "success":
return {
"status": "failed",
"references": [
{k: v for k, v in ref.items() if k != "path"} | {"path": str(Path(ref["path"]).resolve())}
for ref in references
],
"successful": [],
"failed": [target_db_info],
"ids_file": "",
"source_matrix": "",
"members": 0,
"source_by_gene": {},
"target_db": target_db_info,
}
for ref in references:
result = run_blast_analysis(
input_dir=input_dir,
output_dir=output_dir,
prefix=prefix,
ref_file=Path(ref["path"]),
pep_file=pep_file,
cpu=cpu,
evalue=evalue,
identity_min=identity_min,
reference_label=str(ref.get("label") or infer_reference_label(Path(ref["path"]))),
target_db_info=target_db_info,
)
if not result:
continue
if result.get("status") != "success":
failed.append(result)
continue
successful.append(result)
label = str(result.get("reference_label") or "reference")
evidence_by_gene = result.get("evidence_by_gene") or {}
for gene_id in result.get("ids") or []:
source_by_gene.setdefault(gene_id, set()).add(label)
evidence = evidence_by_gene.get(gene_id) or {}
query_by_gene.setdefault(gene_id, set()).update(str(q) for q in evidence.get("query_ids") or [])
hit_evalue = evidence.get("best_evalue")
bitscore = evidence.get("best_bitscore")
if hit_evalue is None or bitscore is None:
continue
current = best_by_gene.get(gene_id)
if current is None or hit_evalue < current["best_evalue"] or (
hit_evalue == current["best_evalue"] and bitscore > current["best_bitscore"]
):
best_by_gene[gene_id] = {"best_evalue": hit_evalue, "best_bitscore": bitscore}
merged_ids_file = analysis_dir / f"{prefix}.blast.ids"
source_matrix_file = analysis_dir / f"{prefix}.blast.source_matrix.tsv"
merged_ids = sorted(source_by_gene)
write_id_list(merged_ids, merged_ids_file)
source_matrix_file.parent.mkdir(parents=True, exist_ok=True)
with source_matrix_file.open("w", encoding="utf-8") as handle:
handle.write("gene_id\tblast_reference_sources\tblast_reference_count\tblast_query_ids\tbest_evalue\tbest_bitscore\n")
for gene_id in merged_ids:
sources = sorted(source_by_gene.get(gene_id, set()))
queries = sorted(query_by_gene.get(gene_id, set()))
best = best_by_gene.get(gene_id, {})
handle.write(
f"{gene_id}\t{';'.join(sources)}\t{len(sources)}\t"
f"{';'.join(queries)}\t{best.get('best_evalue', '')}\t{best.get('best_bitscore', '')}\n"
)
return {
"status": "success" if successful else "skipped",
"references": [
{k: v for k, v in ref.items() if k != "path"} | {"path": str(Path(ref["path"]).resolve())}
for ref in references
],
"successful": successful,
"failed": failed,
"ids_file": str(merged_ids_file.resolve()),
"source_matrix": str(source_matrix_file.resolve()),
"members": len(merged_ids),
"source_by_gene": {gene_id: sorted(sources) for gene_id, sources in source_by_gene.items()},
"query_by_gene": {gene_id: sorted(queries) for gene_id, queries in query_by_gene.items()},
"target_db": target_db_info,
"blast_database_role": "target_proteome",
"blast_query_role": "reference_family_pep",
"blast_candidate_id_column": "sseqid",
}
def combine_hmm_blast_results(
hmm_ids_file: Path,
blast_ids_file: Optional[Path],
output_dir: Path,
prefix: str,
merge_mode: str = 'intersection'
) -> Path:
"""
整合HMM和Blast结果。
merge_mode: 'intersection'(交集)或 'union'(并集)。
"""
print("\n" + "=" * 60)
print(f"🔄 整合HMM和Blast结果 ({merge_mode}模式)")
print("=" * 60)
hmm_ids = read_ids_from_file(hmm_ids_file)
blast_ids = read_ids_from_file(blast_ids_file) if blast_ids_file else set()
if blast_ids:
if merge_mode == 'intersection':
combined_ids = hmm_ids.intersection(blast_ids)
print(" 合并方式: 交集 (HMM ∩ BLAST)")
else:
combined_ids = hmm_ids.union(blast_ids)
print(" 合并方式: 并集 (HMM ∪ BLAST)")
else:
combined_ids = hmm_ids
print(" 无BLAST结果,仅使用HMM结果")
combine_dir = output_dir / "combine"
combine_dir.mkdir(parents=True, exist_ok=True)
combined_file = combine_dir / f"{prefix}.combined.ids"
write_id_list(sorted(combined_ids), combined_file)
print("\n📊 整合结果:")
print(f" HMM鉴定基因数: {len(hmm_ids)}")
if blast_ids_file:
print(f" Blast鉴定基因数: {len(blast_ids)}")
print(f" 整合后基因数: {len(combined_ids)}")
print(f" 结果文件: {combined_file}")
for i, gene_id in enumerate(sorted(combined_ids)[:10], 1):
print(f" {i:2d}. {gene_id}")
if len(combined_ids) > 10:
print(f" ... 还有 {len(combined_ids)-10} 个基因")
return combined_file
def main():
print(f"🐛 DEBUG: Python {sys.version}")
print(f"🐛 DEBUG: 脚本路径: {Path(__file__).resolve()}")
print(f"🐛 DEBUG: 当前目录: {Path.cwd()}")
try:
test_versions = get_tool_versions()
print(f"🛠️ DEBUG: 工具版本测试: {test_versions}")
except Exception as e:
print(f"❌ DEBUG: 获取工具版本失败: {e}")
script_start_time = datetime.now()
parser = argparse.ArgumentParser(description='Identify stage for web pipeline - 多物种修订版')
parser.add_argument('--input-dir', required=True, help='input/<job_id>/ (用户上传文件目录)')
parser.add_argument('--workspace', required=True, help='workspace/<job_id>/')
parser.add_argument('--prefix', required=True, help='分析前缀,例如 At.WRKY')
parser.add_argument('--cpu', type=int, default=8)
parser.add_argument('--force', action='store_true', help='overwrite existing results')
parser.add_argument('--required-pfams', help='comma-separated Pfam accessions, e.g. PF03110,PFxxxxx')
parser.add_argument('--dom-i-evalue-max', type=float, default=1e-3, help='domain i-Evalue threshold')
parser.add_argument('--hmm-cov-min', type=float, default=0.3, help='HMM coverage threshold')
parser.add_argument('--blast-evalue', type=float, default=1e-7, help='Blast E-value threshold')
parser.add_argument('--blast-identity-min', type=float, default=30.0, help='minimum BLAST percent identity for reference-protein hits')
parser.add_argument('--cut-ga', action='store_true', help='use Pfam GA thresholds')
parser.add_argument('--pfam-dir', help='directory containing Pfam-A.hmm (default: env BBA_PFAM_DIR)')
parser.add_argument('--retention-days', type=int, default=7, help='days to keep workspace')
parser.add_argument('--ref-root', help='reference root directory')
parser.add_argument('--ref-species', help='comma-separated reference species names; can also use env BBA_REF_SPECIES')
parser.add_argument('--ref-file', help='指定参考序列文件(用于多个参考序列时选择,如 At_NAC_pep.fasta)')
parser.add_argument('--target-species', help='comma-separated target species codes, e.g. Lc,Dl')
parser.add_argument('--scope-file', help='Scope JSON from apps/cli/resolve_scope.py')
parser.add_argument('--log-file', help='日志文件路径(从pipeline_backend传入)')
parser.add_argument('--hmm-combine-mode', choices=['intersection', 'union'], default='union',
help='多HMM文件时的整合方式(交集intersection/并集union),默认并集')
parser.add_argument('--hmm-blast-merge-mode', choices=['intersection', 'union'], default=None,
help='HMM和BLAST结果整合方式(交集intersection/并集union)')
parser.add_argument('--merge-mode', choices=['intersection', 'union'], default='intersection',
help='HMM和BLAST结果整合方式(默认交集intersection)')
parser.add_argument('--skip-ref-warning', action='store_true',
help='CLI模式:跳过无参考序列的提醒,直接继续')
parser.add_argument('--countdown-action', choices=['continue', 'cancel'], default='continue',
help='前端倒计时结束后发送的动作')
args = parser.parse_args()
in_dir = Path(args.input_dir).resolve()
ws_root = Path(args.workspace).resolve()
prefix = args.prefix
scope_data: Dict[str, Any] = {}
if args.scope_file:
scope_path = Path(args.scope_file).resolve()
if not scope_path.exists():
raise RuntimeError(f"--scope-file 不存在: {scope_path}")
scope_data = read_scope(scope_path)
print(f"🧭 Scope: {scope_data.get('scope_id', scope_path.name)}")
print(f" target: {','.join(scope_data.get('target_species') or []) or '未指定'}")
print(f" reference: {','.join(scope_data.get('reference_species') or []) or '未指定'}")
print(f" comparative: {','.join(scope_data.get('comparative_species') or []) or '未指定'}")
tool_versions = get_tool_versions()
print(f"🔧 工具版本: {tool_versions}")
inputs_info = []
for file_path in in_dir.iterdir():
if file_path.is_file():
inputs_info.append({
"path": str(file_path.relative_to(in_dir)),
"full_path": str(file_path.resolve()),
"sha256": compute_file_hash(file_path),
"size": file_path.stat().st_size,
"mtime": file_path.stat().st_mtime
})
if not in_dir.exists():
raise RuntimeError(f"Input directory not found: {in_dir}")
log_file_path = None
if args.log_file:
log_file_path = Path(args.log_file).resolve()
print(f"📝 日志文件: {log_file_path}")
print(f"🌱 Identify stage for {prefix} (多物种修订版)")
print(f" input目录: {in_dir}")
print(f" workspace目录: {ws_root}")
# 多物种支持
gene_family = extract_gene_family(in_dir, prefix, ws_root)
normalized_prefix = normalize_identify_prefix(prefix, gene_family)
if normalized_prefix != prefix:
print(f"🧭 输出标签归一化: {prefix} -> {normalized_prefix}")
prefix = normalized_prefix
print(f"🧬 基因家族: {gene_family}")
reference_policy = read_reference_policy_from_config(in_dir)
reference_sets = collect_reference_files(
in_dir,
gene_family=gene_family,
workspace=ws_root,
explicit_ref_file=args.ref_file,
)
if reference_sets and args.ref_species:
requested_refs = {item.strip() for item in re.split(r"[,;]", args.ref_species) if item.strip()}
before_count = len(reference_sets)
reference_sets = [
ref for ref in reference_sets
if str(ref.get("species") or "") in requested_refs or str(ref.get("label") or "") in requested_refs
]
print(f"📚 根据 --ref-species 筛选参考集合: {before_count} -> {len(reference_sets)}")
missing_refs = requested_refs - {str(ref.get("species") or ref.get("label")) for ref in reference_sets} - {str(ref.get("label") or ref.get("species")) for ref in reference_sets}
if missing_refs:
print(f"⚠️ 未找到指定参考物种家族蛋白: {', '.join(sorted(missing_refs))}")
if not reference_sets:
print("ℹ️ config.xlsx 未声明 identify 参考家族蛋白,后续将仅使用 HMM 结果进行鉴定")
else:
print(f"📚 参考家族蛋白集合数: {len(reference_sets)}")
for ref in reference_sets:
print(f" - {ref['label']}: {Path(ref['path']).name}")
blast_enabled = bool(reference_sets)
identify_strategy = "hmm_plus_blast" if blast_enabled else "hmm_only"
target_species_value = args.target_species
if not target_species_value and scope_data.get("target_species"):
target_species_value = ",".join(scope_data.get("target_species") or [])
if not target_species_value and "." in prefix:
prefix_species = prefix.split(".", 1)[0].strip()
if prefix_species and prefix_species not in {"Target", "Multi"}:
target_species_value = prefix_species
print(f"🎯 Scope 未指定目标物种,已从 prefix 推断: {prefix_species}")
selected_target_species = {item.strip() for item in str(target_species_value or "").split(",") if item.strip()}
if selected_target_species:
print(f"🎯 用户指定目标物种: {', '.join(sorted(selected_target_species))}")
target_pep_files = find_pep_files(in_dir, ws_root, selected_target_species or None)
if not target_pep_files:
raise RuntimeError("未找到目标物种 pep 文件")
if selected_target_species:
found_species = {pep_file.stem.replace(".pep", "").split(".")[0] for pep_file in target_pep_files}
missing_species = sorted(selected_target_species - found_species)
if missing_species:
raise RuntimeError("显式指定的目标物种缺少可用全蛋白文件,Identify 未执行: " + ", ".join(missing_species))
print(f"🎯 目标物种数: {len(target_pep_files)}")
for f in target_pep_files:
print(f" - {f.name}")
print("\n🎯 Pfam获取策略:")
use_user_hmm = False
query_hmm = None
required_pfams = None
pfam_fetch_map: Dict[str, str] = {}
hmm_models_audit: Dict[str, Any] = {}
hmm_run_summaries: List[Dict[str, Any]] = []
hmm_combine_mode = None
user_hmms = find_user_query_hmms(in_dir)
if not user_hmms:
if not args.required_pfams:
config_pfams = read_required_pfams_from_config(in_dir, ws_root)
if config_pfams:
args.required_pfams = config_pfams
print(f"✅ 根据 config.xlsx 自动补全 Pfam: {args.required_pfams}")
if not args.required_pfams:
inferred_pfam = get_pfam_from_rag(gene_family)
if inferred_pfam:
args.required_pfams = inferred_pfam
print(f"✅ 根据基因家族 {gene_family} 自动补全 Pfam: {args.required_pfams}")
if args.required_pfams:
cache_dir = in_dir / "genome_prepare" / "hmm_cache"
try:
query_hmm, required_pfams, pfam_fetch_map = build_required_pfam_hmm(
args.required_pfams,
args.pfam_dir,
cache_dir,
)
hmm_models_audit = assert_hmm_models_complete(query_hmm, required_pfams, pfam_fetch_map)
except Exception as exc:
error_msg = f"Identify Pfam/HMM model preparation failed: {exc}"
write_failed_identify_manifest(
ws_root,
prefix,
gene_family,
in_dir,
script_start_time,
error_msg,
parameters={
"required_pfams": _split_pfam_queries(args.required_pfams),
"dom_i_evalue_max": args.dom_i_evalue_max,
"hmm_cov_min": args.hmm_cov_min,
"blast_evalue": args.blast_evalue,
"blast_identity_min": args.blast_identity_min,
"hmm_combine_mode": args.hmm_combine_mode,
"hmm_blast_merge_mode": args.hmm_blast_merge_mode or "union",
},
hmm_models=hmm_model_audit(cache_dir / f"{'_'.join(_split_pfam_queries(args.required_pfams))}.hmm", _split_pfam_queries(args.required_pfams)) if _split_pfam_queries(args.required_pfams) else {},
)
raise RuntimeError(error_msg) from exc
user_hmms = [query_hmm]
use_user_hmm = False
print(f"✅ 未上传HMM,已根据 --required-pfams 构建查询HMM: {query_hmm}")
print(f" Pfam映射: {pfam_fetch_map}")
print(f" 实际加载Pfam: {', '.join(hmm_models_audit.get('loaded_accessions') or [])}")
else:
error_data = {
"error_type": "NO_HMM_FILE_ERROR",
"stage": "identify",
"action": "terminate",
"message": "必须上传至少一个HMM文件,或提供 --required-pfams 并配置 Pfam-A.hmm"
}
raise RuntimeError(json.dumps(error_data, ensure_ascii=False))
if False: # 禁用多HMM选择弹窗,改为单HMM模式
print(f"⚠️ 发现 {len(user_hmms)} 个HMM文件:")
for i, hmm in enumerate(user_hmms, 1):
print(f" {i}. {hmm.name}")
if args.hmm_combine_mode:
hmm_combine_mode = args.hmm_combine_mode
use_user_hmm = True
print(f"✅ 使用HMM整合模式: {hmm_combine_mode}")
else:
pause_manifest = {
"manifest_version": "1.0",
"job_id": prefix,
"stage": "identify",
"status": "paused",
"pause_reason": "MULTIPLE_HMM_SELECTION",
"timestamp": datetime.now().isoformat(),
"message": f"发现{len(user_hmms)}个HMM文件,需要用户选择整合方式",
"requires_user_input": True,
"input_type": "selection",
"hmm_files": [{"name": h.name, "size": f"{h.stat().st_size//1024}KB"} for h in user_hmms],
"options": [
{"label": "交集(严格筛选)", "value": "intersection", "description": "序列必须同时匹配所有HMM", "recommended": True},
{"label": "并集(宽松筛选)", "value": "union", "description": "序列匹配任意HMM即可", "recommended": False}
],
"selection_param": "--hmm-combine-mode",
"prefix": effective_prefix,
"workspace": str(ws_root),
"input_dir": str(in_dir)
}
final_dir_temp = ws_root / "final"
final_dir_temp.mkdir(parents=True, exist_ok=True)
manifest_file = final_dir_temp / f"{prefix}.identify.manifest.json"
with open(manifest_file, 'w', encoding='utf-8') as f:
json.dump(pause_manifest, f, indent=2, ensure_ascii=False)
print(f"\n⏸️ 任务暂停:发现多个HMM文件,需要用户选择整合方式")
print(f" Manifest已写入: {manifest_file}")
sys.exit(0)
# 多HMM模式:保留所有用户HMM,并按 --hmm-combine-mode 整合
if len(user_hmms) >= 1 and query_hmm is None:
print(f"✅ 使用用户上传的HMM文件: {user_hmms[0].name}")
use_user_hmm = True
query_hmm = user_hmms[0]
hmm_name = user_hmms[0].stem
required_pfams = _split_pfam_queries(args.required_pfams) if args.required_pfams else [hmm_name]
uploaded_models = [m for hmm in user_hmms for m in parse_hmm_models(hmm)]
loaded_accessions = sorted({m.get("accession") for m in uploaded_models if m.get("accession")})
requested_accessions = [q for q in required_pfams if _is_pfam_accession(q)]
missing_uploaded = sorted(set(requested_accessions) - set(loaded_accessions))
query_name_to_accession = {
str(m.get("name")): str(m.get("accession"))
for m in uploaded_models
if m.get("name") and m.get("accession")
}
hmm_models_audit = {
"status": "failed" if missing_uploaded else "success",
"source": "user_uploaded_hmm",
"requested_pfams": required_pfams,
"loaded_accessions": loaded_accessions,
"missing_requested_pfams": missing_uploaded,
"query_name_to_accession": query_name_to_accession,
"query_hmms": [str(h.resolve()) for h in user_hmms],
"models": uploaded_models,
}
if missing_uploaded:
error_msg = f"Uploaded HMM file(s) do not contain requested Pfam model(s): {', '.join(missing_uploaded)}"
write_failed_identify_manifest(
ws_root,
prefix,
gene_family,
in_dir,
script_start_time,
error_msg,
parameters={
"required_pfams": required_pfams,
"dom_i_evalue_max": args.dom_i_evalue_max,
"hmm_cov_min": args.hmm_cov_min,
"blast_evalue": args.blast_evalue,
"hmm_combine_mode": args.hmm_combine_mode,
"hmm_blast_merge_mode": args.hmm_blast_merge_mode or "union",
},
hmm_models=hmm_models_audit,
)
raise RuntimeError(error_msg)
if len(user_hmms) > 1:
print(f" 发现 {len(user_hmms)} 个HMM文件,将按 {args.hmm_combine_mode} 模式整合")
print(f" HMM名称: {hmm_name}")
# Karpathy: 设置 HMM/BLAST 合并模式默认值
# 用户未指定时,默认使用并集(union/宽松筛选)
if not args.hmm_blast_merge_mode:
args.hmm_blast_merge_mode = "union"
print(f"\n🔄 未指定HMM/BLAST合并模式,默认使用: {args.hmm_blast_merge_mode} (并集/宽松筛选)")
else:
print(f"\n🔄 使用HMM/BLAST合并模式: {args.hmm_blast_merge_mode}")
final_member_files = []
species_results = []
for pep_file in target_pep_files:
file_prefix = pep_file.stem.replace(".pep", "")
species_code = file_prefix.split('.')[0]
print(f"\n{'='*60}")
print(f"🧬 处理目标物种: {file_prefix}")
print(f"{'='*60}")
# 修订 2026-04-26: 输出目录改为 identify/{species}/ 结构
species_workspace = ws_root / "identify" / file_prefix
species_workspace.mkdir(parents=True, exist_ok=True)
paths = prepare_directories(species_workspace)
start_time = datetime.now()
# 修订 2026-04-28: 多HMM支持 - 每个HMM分别运行,根据参数取交集或并集
hmm_ids_files = []
for i, hmm_file in enumerate(user_hmms, 1):
hmm_name = hmm_file.stem
hmm_prefix = f"{file_prefix}.{hmm_name}"
print(f"\n [{i}/{len(user_hmms)}] 运行HMM: {hmm_file.name}")
hmm_domtblout, hmm_ids_file = run_hmm_analysis(
input_dir=in_dir,
output_dir=species_workspace,
prefix=hmm_prefix,
hmm_file=hmm_file,
pep_file=pep_file,
cpu=args.cpu,
evalue=args.dom_i_evalue_max
)
per_model_hits = summarize_domtblout_by_model(
hmm_domtblout,
args.dom_i_evalue_max,
hmm_models_audit.get("query_name_to_accession") if isinstance(hmm_models_audit, dict) else {},
)
hmm_run_summaries.append({
"species": species_code,
"hmm_file": str(hmm_file.resolve()),
"domtblout": str(hmm_domtblout.resolve()),
"ids_file": str(hmm_ids_file.resolve()),
"members": len(read_id_list(hmm_ids_file)),
"per_model_hits": per_model_hits,
})
hmm_ids_files.append(hmm_ids_file)
# 取所有HMM结果的交集或并集(根据参数)
if len(hmm_ids_files) > 1:
# 读取所有HMM的IDs
hmm_id_sets = []
for ids_file in hmm_ids_files:
with open(ids_file, 'r') as f:
ids = set(line.strip() for line in f if line.strip())
hmm_id_sets.append(ids)
# 根据参数决定取交集还是并集
if args.hmm_combine_mode == 'intersection':
print(f"\n🔄 取 {len(hmm_ids_files)} 个HMM结果的交集:")
for i, ids in enumerate(hmm_id_sets):
print(f" HMM {i+1}: {len(ids)} 个基因")
final_hmm_ids = set.intersection(*hmm_id_sets)
print(f" 交集结果: {len(final_hmm_ids)} 个基因")
else: # union (默认)
print(f"\n🔄 取 {len(hmm_ids_files)} 个HMM结果的并集:")
for i, ids in enumerate(hmm_id_sets):
print(f" HMM {i+1}: {len(ids)} 个基因")
final_hmm_ids = set.union(*hmm_id_sets)
print(f" 并集结果: {len(final_hmm_ids)} 个基因")
# 输出结果到 analysis/{species}.{gene_family}.hmm.ids
analysis_dir = species_workspace / "analysis"
hmm_ids_file = analysis_dir / f"{file_prefix}.hmm.ids"
with open(hmm_ids_file, 'w') as f:
for gene_id in sorted(final_hmm_ids):
f.write(f"{gene_id}\n")
print(f" 输出: {hmm_ids_file.name}")
blast_result = run_multi_reference_blast_analysis(
input_dir=in_dir,
output_dir=species_workspace,
prefix=file_prefix,
references=reference_sets,
pep_file=pep_file,
cpu=args.cpu,
evalue=args.blast_evalue,
identity_min=args.blast_identity_min,
) if reference_sets else {
"status": "skipped",
"references": [],
"successful": [],
"failed": [],
"ids_file": "",
"source_matrix": "",
"members": 0,
"source_by_gene": {},
"target_db": {},
"blast_database_role": "target_proteome",
"blast_query_role": "reference_family_pep",
"blast_candidate_id_column": "sseqid",
}
blast_ids_file = Path(blast_result["ids_file"]) if blast_result.get("ids_file") else None
# 修订 2026-05-18/06-13: BLAST参考可选;多个参考家族蛋白逐路运行后按target gene取并集。
with open(hmm_ids_file, 'r') as f:
hmm_ids = set(line.strip() for line in f if line.strip())
blast_ids = read_ids_from_file(blast_ids_file) if blast_ids_file else set()
species_hmm_members = len(hmm_ids)
species_blast_members = len(blast_ids)
# 根据用户选择的模式合并结果
if args.hmm_blast_merge_mode == "intersection":
final_ids = (hmm_ids & blast_ids) if blast_ids else hmm_ids
print(f"\n🔄 使用交集模式: {len(final_ids)} 个基因" + ("(无BLAST时退回HMM)" if not blast_ids else ""))
else: # union
final_ids = hmm_ids | blast_ids
print(f"\n🔄 使用并集模式: {len(final_ids)} 个基因")
# 写入合并后的结果文件
combined_ids_file = species_workspace / "combine" / f"{file_prefix}.combined.ids"
combined_ids_file.parent.mkdir(parents=True, exist_ok=True)
with open(combined_ids_file, 'w') as f:
for gene_id in sorted(final_ids):
f.write(f"{gene_id}\n")
final_dir = species_workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
final_ids_file = final_dir / f"{file_prefix}.final_gfmembers.id"
with open(final_ids_file, 'w') as f:
for gene_id in final_ids:
f.write(f"{gene_id}\n")
final_pep_file = final_dir / f"{file_prefix}.final_gfmembers.pep.fa"
write_fasta_subset(pep_file, final_ids, final_pep_file)
final_member_files.append(final_pep_file)
species_results.append({
"species": species_code,
"file_prefix": file_prefix,
"input_file": str(pep_file.name),
"final_members": len(final_ids),
"hmm_members": species_hmm_members,
"blast_members": species_blast_members,
"blast_reference_count": len(blast_result.get("successful") or []),
"blast_result": {
"status": blast_result.get("status"),
"ids_file": str(Path(blast_result["ids_file"]).relative_to(ws_root)) if blast_result.get("ids_file") else "",
"source_matrix": str(Path(blast_result["source_matrix"]).relative_to(ws_root)) if blast_result.get("source_matrix") else "",
"successful": [
{k: v for k, v in item.items() if k not in {"ids", "evidence_by_gene"}}
for item in (blast_result.get("successful") or [])
],
"failed": blast_result.get("failed") or [],
"target_db": blast_result.get("target_db") or {},
"blast_database_role": blast_result.get("blast_database_role") or "target_proteome",
"blast_query_role": blast_result.get("blast_query_role") or "reference_family_pep",
"blast_candidate_id_column": blast_result.get("blast_candidate_id_column") or "sseqid",
},
"candidate_members": len(final_ids),
"final_pep_file": str(final_pep_file.relative_to(ws_root)),
"final_id_file": str(final_ids_file.relative_to(ws_root)),
})
print(f"✅ {file_prefix}: {len(final_ids)} 个成员")
final_dir = ws_root / "final"
final_dir.mkdir(parents=True, exist_ok=True)
# Use role-aware target naming for target-scope identify outputs. Older
# Multi.* names were ambiguous once target/reference/comparative scopes
# became explicit.
target_prefix = f"Target.{gene_family}"
legacy_multi_prefix = f"Multi.{gene_family}"
effective_prefix = prefix or target_prefix
if effective_prefix == legacy_multi_prefix:
effective_prefix = target_prefix
single_species_artifacts = []
for r in species_results:
species = r["species"]
single_prefix = species_prefix_for(species, gene_family)
compat_prefix = f"Target.{species}.{gene_family}"
source_pep = ws_root / r["final_pep_file"]
source_id = ws_root / r["final_id_file"]
target_pep = final_dir / f"{single_prefix}.identified_members.pep.fa"
target_id = final_dir / f"{single_prefix}.identified_members.id"
compat_pep = final_dir / f"{compat_prefix}.identified_members.pep.fa"
compat_id = final_dir / f"{compat_prefix}.identified_members.id"
if source_pep.exists():
copy2_if_different(source_pep, target_pep)
if compat_pep != target_pep:
copy2_if_different(source_pep, compat_pep)
if source_id.exists():
copy2_if_different(source_id, target_id)
if compat_id != target_id:
copy2_if_different(source_id, compat_id)
r["canonical_prefix"] = single_prefix
r["compat_prefix"] = compat_prefix
r["final_pep_file_in_final"] = f"final/{target_pep.name}"
r["final_id_file_in_final"] = f"final/{target_id.name}"
r["compat_pep_file_in_final"] = f"final/{compat_pep.name}"
r["compat_id_file_in_final"] = f"final/{compat_id.name}"
single_species_artifacts.append({
"species": species,
"prefix": single_prefix,
"compat_prefix": compat_prefix,
"protein_fasta": target_pep,
"id_list": target_id,
"compat_protein_fasta": compat_pep,
"compat_id_list": compat_id,
"members": r["final_members"],
})
single_target_species = ""
if len(species_results) == 1:
single_target_species = str(species_results[0].get("species") or "").strip()
merged_pep_file = final_dir / f"{effective_prefix}.identified_members.pep.fa"
merge_final_members(final_member_files, merged_pep_file)
merged_id_file = final_dir / f"{effective_prefix}.identified_members.id"
merged_id_count = write_fasta_ids(merged_pep_file, merged_id_file)
interproscan_tsv = final_dir / f"{effective_prefix}.identified_members.interproscan.tsv"
interproscan_result = run_interproscan_domain_check(merged_pep_file, interproscan_tsv)
interproscan_status_file = final_dir / f"{effective_prefix}.interproscan_domain_check.txt"
write_interproscan_status_file(interproscan_status_file, interproscan_result)
domain_review_tsv = final_dir / f"{effective_prefix}.domain_review.tsv"
domain_review_result = write_domain_review_from_interproscan(
interproscan_tsv,
required_pfams or [],
domain_review_tsv,
species="merged",
)
for r in species_results:
species = r["species"]
species_prefix = species_prefix_for(species, gene_family)
compat_species_prefix = f"Target.{species}.{gene_family}"
source_id = ws_root / r["final_id_file"]
species_original_ids = read_id_list(source_id)
species_review_tsv = final_dir / f"{species_prefix}.domain_review.tsv"
species_review_result = write_domain_review_from_interproscan(
interproscan_tsv,
required_pfams or [],
species_review_tsv,
species=species,
id_filter=set(species_original_ids),
)
r["domain_review_file_in_final"] = f"final/{species_review_tsv.name}"
r["domain_review"] = {
"status": species_review_result.get("status"),
"rows": species_review_result.get("rows", 0),
"default_keep_count": species_review_result.get("default_keep_count", 0),
}
default_domain_ids = domain_review_result.get("default_keep_ids", []) if domain_review_result.get("status") == "success" else []
if default_domain_ids:
curated_default_id = final_dir / f"{effective_prefix}.curated_members.id"
curated_default_pep = final_dir / f"{effective_prefix}.curated_members.pep.fa"
write_id_list(default_domain_ids, curated_default_id)
candidate_pep_file = merged_pep_file.with_name(f"{effective_prefix}.candidate_members.pep.fa")
candidate_id_file = merged_id_file.with_name(f"{effective_prefix}.candidate_members.id")
if merged_pep_file.exists():
copy2_if_different(merged_pep_file, candidate_pep_file)
if merged_id_file.exists():
copy2_if_different(merged_id_file, candidate_id_file)
write_fasta_subset(candidate_pep_file, default_domain_ids, curated_default_pep)
write_fasta_subset(candidate_pep_file, default_domain_ids, merged_pep_file)
merged_id_count = write_id_list(default_domain_ids, merged_id_file)
interproscan_result["default_domain_filter"] = {
"enabled": True,
"required_domains": required_pfams or [],
"candidate_members": len(read_id_list(candidate_id_file)),
"kept_members": merged_id_count,
"curated_id_file": str(curated_default_id.resolve()),
"candidate_pep_file": str(candidate_pep_file.resolve()),
}
print(f"✅ 默认结构域过滤: {merged_id_count} 个成员保留(required domains: {','.join(required_pfams or [])})")
for r in species_results:
species = r["species"]
species_prefix = species_prefix_for(species, gene_family)
compat_species_prefix = f"Target.{species}.{gene_family}"
source_pep = ws_root / r["final_pep_file"]
source_id = ws_root / r["final_id_file"]
species_original_ids = read_id_list(source_id)
species_default_ids = [seq_id for seq_id in species_original_ids if seq_id in set(default_domain_ids)]
target_pep = final_dir / f"{species_prefix}.identified_members.pep.fa"
target_id = final_dir / f"{species_prefix}.identified_members.id"
target_curated_pep = final_dir / f"{species_prefix}.curated_members.pep.fa"
target_curated_id = final_dir / f"{species_prefix}.curated_members.id"
compat_pep = final_dir / f"{compat_species_prefix}.identified_members.pep.fa"
compat_id = final_dir / f"{compat_species_prefix}.identified_members.id"
compat_curated_pep = final_dir / f"{compat_species_prefix}.curated_members.pep.fa"
compat_curated_id = final_dir / f"{compat_species_prefix}.curated_members.id"
write_id_list(species_default_ids, target_id)
write_id_list(species_default_ids, target_curated_id)
write_fasta_subset(source_pep, species_default_ids, target_pep)
write_fasta_subset(source_pep, species_default_ids, target_curated_pep)
if compat_pep != target_pep:
copy2_if_different(target_pep, compat_pep)
if compat_id != target_id:
copy2_if_different(target_id, compat_id)
if compat_curated_pep != target_curated_pep:
copy2_if_different(target_curated_pep, compat_curated_pep)
if compat_curated_id != target_curated_id:
copy2_if_different(target_curated_id, compat_curated_id)
if single_target_species and species == single_target_species:
copy2_if_different(target_pep, merged_pep_file)
copy2_if_different(target_id, merged_id_file)
write_id_list(species_default_ids, curated_default_id)
copy2_if_different(target_curated_pep, curated_default_pep)
merged_id_count = len(species_default_ids)
interproscan_result.setdefault("default_domain_filter", {})["single_target_compat_sync"] = {
"species": species,
"target_prefix": species_prefix,
"legacy_target_prefix": compat_species_prefix,
"compat_prefix": effective_prefix,
"members": merged_id_count,
}
r["final_members_before_domain_filter"] = r["final_members"]
r["final_members"] = len(species_default_ids)
elif interproscan_result.get("status") == "success" and required_pfams:
warning_msg = "InterProScan完成,但未找到命中目标Pfam的成员;保留HMM/BLAST候选结果,需人工检查domain_review.tsv。"
print(f"⚠️ {warning_msg}")
interproscan_result["default_domain_filter"] = {
"enabled": False,
"required_domains": required_pfams or [],
"reason": warning_msg,
}
hmm_models_audit = dict(hmm_models_audit or {})
hmm_models_audit.setdefault("status", "success")
hmm_models_audit["run_summaries"] = hmm_run_summaries
merged_per_model_hits: Dict[str, int] = {}
for item in hmm_run_summaries:
for key, value in (item.get("per_model_hits") or {}).items():
merged_per_model_hits[key] = merged_per_model_hits.get(key, 0) + int(value or 0)
hmm_models_audit["per_model_hits"] = merged_per_model_hits
missing_hmm_models = hmm_models_audit.get("missing_requested_pfams") or []
if missing_hmm_models:
raise RuntimeError(f"Identify HMM audit failed, missing requested Pfam model(s): {', '.join(missing_hmm_models)}")
candidate_members_count = sum(int(r.get("final_members_before_domain_filter", r.get("candidate_members", r.get("final_members", 0)))) for r in species_results)
hmm_members_count = sum(int(r.get("hmm_members", 0)) for r in species_results)
blast_members_count = sum(int(r.get("blast_members", 0)) for r in species_results)
final_members_count = sum(int(r.get("final_members", 0)) for r in species_results)
hmm_audit_file = final_dir / f"{effective_prefix}.hmm_model_audit.txt"
audit_lines = [
f"status: {hmm_models_audit.get('status', 'unknown')}",
"requested_pfams: " + ",".join(hmm_models_audit.get("requested_pfams") or []),
"loaded_accessions: " + ",".join(hmm_models_audit.get("loaded_accessions") or []),
"missing_requested_pfams: " + ",".join(missing_hmm_models),
f"query_hmm: {hmm_models_audit.get('query_hmm') or ';'.join(hmm_models_audit.get('query_hmms') or [])}",
"",
"per_model_hits:",
]
for key, value in sorted(merged_per_model_hits.items()):
audit_lines.append(f"- {key}: {value}")
hmm_audit_file.write_text("\n".join(audit_lines) + "\n", encoding="utf-8")
manifest_data = {
"manifest_version": "1.0",
"job_id": ws_root.name,
"run_id": f"identify_{script_start_time:%Y%m%d_%H%M%S}",
"stage": "identify",
"prefix": effective_prefix,
"output_prefix": effective_prefix,
"canonical_member_prefix": species_results[0].get("canonical_prefix") if len(species_results) == 1 else "",
"analysis_route": "single_species" if len(species_results) == 1 else "multi_target",
"identify_strategy": identify_strategy,
"blast_enabled": blast_enabled,
"result_group": "gene_family_identify",
"gene_family": gene_family,
"status": "success",
"timestamp": datetime.now().isoformat(),
"start_time": script_start_time.isoformat(),
"end_time": datetime.now().isoformat(),
"elapsed_sec": (datetime.now() - script_start_time).total_seconds(),
"target_species": scope_data.get("target_species") or [r["species"] for r in species_results],
"reference_species": scope_data.get("reference_species") or (reference_policy.get("primary_reference_species") or []),
"comparative_species": scope_data.get("comparative_species") or [],
"selected_species": scope_data.get("selected_species") or [r["species"] for r in species_results],
"scope_id": scope_data.get("scope_id", ""),
"scope_mode": scope_data.get("scope_mode", ""),
"scope_file": str(Path(args.scope_file).resolve()) if args.scope_file else "",
"scope": {
"family": scope_data.get("family", gene_family),
"prefix": scope_data.get("prefix", effective_prefix),
"scope_id": scope_data.get("scope_id", ""),
"scope_mode": scope_data.get("scope_mode", ""),
"target_species": scope_data.get("target_species") or [r["species"] for r in species_results],
"reference_species": scope_data.get("reference_species") or (reference_policy.get("primary_reference_species") or []),
"comparative_species": scope_data.get("comparative_species") or [],
"selected_species": scope_data.get("selected_species") or [r["species"] for r in species_results],
},
"species_count": len(species_results),
"species_results": species_results,
"reference_sets": [
{k: v for k, v in ref.items() if k != "path"} | {"path": str(Path(ref["path"]).resolve())}
for ref in reference_sets
],
"primary_reference_species": reference_policy.get("primary_reference_species") or (scope_data.get("reference_species") or []),
"identify_blast_reference_species": reference_policy.get("identify_blast_reference_species") or [str(ref.get("species") or ref.get("label")) for ref in reference_sets],
"reference_policy_warnings": reference_policy.get("warnings") or [],
"workspace_root": str(ws_root.resolve()),
"workspace": str(final_dir.resolve()),
"input_dir": str(in_dir.resolve()),
"tool_versions": tool_versions,
"cmd": sys.argv,
"inputs": inputs_info,
"platform": platform.platform(),
"python_version": sys.version.split()[0],
"parameters": {
"required_pfams": required_pfams or [],
"dom_i_evalue_max": args.dom_i_evalue_max,
"hmm_cov_min": args.hmm_cov_min,
"blast_evalue": args.blast_evalue,
"cut_ga": bool(args.cut_ga),
"cpu": args.cpu,
"hmm_combine_mode": args.hmm_combine_mode,
"hmm_blast_merge_mode": args.hmm_blast_merge_mode or "union",
"identify_strategy": identify_strategy,
"blast_enabled": blast_enabled,
"blast_reference_mode": "per_reference_union",
"blast_database_role": "target_proteome",
"blast_query_role": "reference_family_pep",
"blast_candidate_id_column": "sseqid",
"blast_reference_sets": [str(ref.get("label")) for ref in reference_sets],
"identify_blast_reference_species": reference_policy.get("identify_blast_reference_species") or [str(ref.get("species") or ref.get("label")) for ref in reference_sets],
"primary_reference_species": reference_policy.get("primary_reference_species") or (scope_data.get("reference_species") or []),
},
"blast_method": {
"database_role": "target_proteome",
"query_role": "reference_family_pep",
"candidate_id_column": "sseqid",
"reference_mode": "per_reference_union",
},
"metrics": {
"total_proteins": sum(count_fasta_records(p) for p in target_pep_files),
"hmm_members": hmm_members_count,
"blast_members": blast_members_count,
"blast_enabled": blast_enabled,
"identify_strategy": identify_strategy,
"blast_reference_sets": len(reference_sets),
"candidate_members": candidate_members_count,
"identified_members_count": final_members_count,
"final_members": final_members_count,
},
"hmm_models": hmm_models_audit,
"domain_check": {"interproscan": interproscan_result, "review": domain_review_result},
"warnings": [
{"step": "reference_policy", "message": warning}
for warning in (reference_policy.get("warnings") or [])
],
"artifacts": {}
}
for r in species_results:
species = r["species"]
final_pep_path = ws_root / r["final_pep_file"]
final_id_path = ws_root / r["final_id_file"]
final_pep_in_final = Path(r.get("final_pep_file_in_final", ""))
final_id_in_final = Path(r.get("final_id_file_in_final", ""))
compat_pep_in_final = Path(r.get("compat_pep_file_in_final", ""))
compat_id_in_final = Path(r.get("compat_id_file_in_final", ""))
manifest_data["artifacts"][f"{species}_identified_members"] = {
"artifact": f"{species}_identified_members",
"file": f"{r['file_prefix']}.final_gfmembers.pep.fa",
"full_path": str(final_pep_path.resolve()),
"relpath_or_hint": r["final_pep_file"],
"type": "fasta",
"species": species,
"members": r["final_members"]
}
final_species_pep = final_dir / final_pep_in_final.name if final_pep_in_final.name else None
final_species_id = final_dir / final_id_in_final.name if final_id_in_final.name else None
if final_species_pep and final_species_pep.exists():
manifest_data["artifacts"][f"target_{species}_identified_members_pep"] = {
"artifact": f"target_{species}_identified_members_pep",
"file": final_species_pep.name,
"full_path": str(final_species_pep.resolve()),
"relpath_or_hint": f"final/{final_species_pep.name}",
"type": "fasta",
"role": "target",
"canonical_prefix": r.get("canonical_prefix"),
"species": species,
"members": r["final_members"],
"description": "Single target species identified protein FASTA",
}
if final_species_id and final_species_id.exists():
manifest_data["artifacts"][f"target_{species}_identified_members_id"] = {
"artifact": f"target_{species}_identified_members_id",
"file": final_species_id.name,
"full_path": str(final_species_id.resolve()),
"relpath_or_hint": f"final/{final_species_id.name}",
"type": "id_list",
"role": "target",
"canonical_prefix": r.get("canonical_prefix"),
"species": species,
"members": r["final_members"],
"description": "Single target species identified member IDs",
}
compat_species_pep = final_dir / compat_pep_in_final.name if compat_pep_in_final.name else None
compat_species_id = final_dir / compat_id_in_final.name if compat_id_in_final.name else None
if compat_species_pep and compat_species_pep.exists():
manifest_data["artifacts"][f"target_{species}_identified_members_pep_compat"] = {
"artifact": f"target_{species}_identified_members_pep_compat",
"file": compat_species_pep.name,
"full_path": str(compat_species_pep.resolve()),
"relpath_or_hint": f"final/{compat_species_pep.name}",
"type": "fasta",
"role": "target",
"species": species,
"canonical_prefix": r.get("canonical_prefix"),
"description": "Target-prefixed identified member compatibility copy",
}
if compat_species_id and compat_species_id.exists():
manifest_data["artifacts"][f"target_{species}_identified_members_id_compat"] = {
"artifact": f"target_{species}_identified_members_id_compat",
"file": compat_species_id.name,
"full_path": str(compat_species_id.resolve()),
"relpath_or_hint": f"final/{compat_species_id.name}",
"type": "id_list",
"role": "target",
"species": species,
"canonical_prefix": r.get("canonical_prefix"),
"description": "Target-prefixed identified member compatibility copy",
}
species_review = final_dir / Path(r.get("domain_review_file_in_final", "")).name if r.get("domain_review_file_in_final") else None
if species_review and species_review.exists():
manifest_data["artifacts"][f"target_{species}_domain_review"] = {
"artifact": f"target_{species}_domain_review",
"file": species_review.name,
"full_path": str(species_review.resolve()),
"relpath_or_hint": f"final/{species_review.name}",
"type": "tsv",
"role": "target",
"species": species,
"description": "Species-specific Pfam/CDD domain review table for identify member confirmation",
"summary": r.get("domain_review", {}),
}
for r in species_results:
blast_result = r.get("blast_result") or {}
source_matrix = blast_result.get("source_matrix")
if source_matrix:
source_matrix_path = ws_root / source_matrix
if source_matrix_path.exists():
manifest_data["artifacts"][f"{r['species']}_blast_source_matrix"] = {
"artifact": f"{r['species']}_blast_source_matrix",
"file": source_matrix_path.name,
"full_path": str(source_matrix_path.resolve()),
"relpath_or_hint": source_matrix,
"type": "tsv",
"species": r["species"],
"description": "Per-target candidate support from reference family protein queries against the target whole-proteome BLAST database",
"blast_database_role": "target_proteome",
"blast_query_role": "reference_family_pep",
"blast_candidate_id_column": "sseqid",
"size": source_matrix_path.stat().st_size,
}
ids_file = blast_result.get("ids_file")
if ids_file:
ids_path = ws_root / ids_file
if ids_path.exists():
manifest_data["artifacts"][f"{r['species']}_blast_results"] = {
"artifact": f"{r['species']}_blast_results",
"file": ids_path.name,
"full_path": str(ids_path.resolve()),
"relpath_or_hint": ids_file,
"type": "id_list",
"species": r["species"],
"members": r.get("blast_members", 0),
"description": "Union of per-reference BLAST candidate target IDs parsed from outfmt6 sseqid",
"blast_database_role": "target_proteome",
"blast_query_role": "reference_family_pep",
"blast_candidate_id_column": "sseqid",
"size": ids_path.stat().st_size,
}
for item in blast_result.get("successful") or []:
blast_output = item.get("blast_output")
if not blast_output:
continue
blast_path = Path(blast_output)
if not blast_path.exists():
continue
label = sanitize_token(str(item.get("reference_label") or "reference"))
manifest_data["artifacts"][f"{r['species']}_blast_{label}_out"] = {
"artifact": f"{r['species']}_blast_{label}_out",
"file": blast_path.name,
"full_path": str(blast_path.resolve()),
"relpath_or_hint": str(blast_path.relative_to(ws_root)) if blast_path.is_relative_to(ws_root) else str(blast_path),
"type": "blast_outfmt6",
"species": r["species"],
"reference_label": label,
"members": item.get("members", 0),
"description": "Raw BLAST outfmt 6 output: reference family protein query against target whole-proteome database",
"query_file": item.get("query_file"),
"query_role": item.get("query_role") or "reference_family_pep",
"target_database_input": item.get("target_database_input"),
"target_database_role": item.get("target_database_role") or "target_proteome",
"target_db_prefix": item.get("target_db_prefix"),
"target_database_files": item.get("target_database_files") or [],
"candidate_id_column": item.get("candidate_id_column") or "sseqid",
"size": blast_path.stat().st_size,
}
manifest_data["artifacts"]["hmm_model_audit"] = {
"artifact": "hmm_model_audit",
"file": hmm_audit_file.name,
"full_path": str(hmm_audit_file.resolve()),
"relpath_or_hint": f"final/{hmm_audit_file.name}",
"type": "txt",
"size": hmm_audit_file.stat().st_size if hmm_audit_file.exists() else 0,
"description": "Audit of requested versus loaded HMM/Pfam models for identify",
"summary": {
"requested_pfams": hmm_models_audit.get("requested_pfams") or [],
"loaded_accessions": hmm_models_audit.get("loaded_accessions") or [],
"missing_requested_pfams": hmm_models_audit.get("missing_requested_pfams") or [],
"per_model_hits": hmm_models_audit.get("per_model_hits") or {},
},
}
query_hmm_path = hmm_models_audit.get("query_hmm")
if query_hmm_path and Path(str(query_hmm_path)).exists():
query_hmm_artifact = Path(str(query_hmm_path))
manifest_data["artifacts"]["query_hmm"] = {
"artifact": "query_hmm",
"file": query_hmm_artifact.name,
"full_path": str(query_hmm_artifact.resolve()),
"relpath_or_hint": str(query_hmm_artifact.resolve()),
"type": "hmm",
"size": query_hmm_artifact.stat().st_size,
"description": "Merged query HMM used by identify",
}
manifest_data["artifacts"]["identified_members"] = {
"artifact": "identified_members",
"file": merged_pep_file.name,
"full_path": str(merged_pep_file.resolve()),
"relpath_or_hint": f"final/{merged_pep_file.name}",
"type": "fasta",
"size": merged_pep_file.stat().st_size if merged_pep_file.exists() else 0,
"merged_from": [r["file_prefix"] for r in species_results]
}
manifest_data["artifacts"]["identified_members_id"] = {
"artifact": "identified_members_id",
"file": merged_id_file.name,
"full_path": str(merged_id_file.resolve()),
"relpath_or_hint": f"final/{merged_id_file.name}",
"type": "id_list",
"size": merged_id_file.stat().st_size if merged_id_file.exists() else 0,
"members": merged_id_count,
"derived_from": merged_pep_file.name
}
if domain_review_tsv.exists():
manifest_data["artifacts"]["domain_review"] = {
"artifact": "domain_review",
"file": domain_review_tsv.name,
"full_path": str(domain_review_tsv.resolve()),
"relpath_or_hint": f"final/{domain_review_tsv.name}",
"type": "tsv",
"size": domain_review_tsv.stat().st_size,
"derived_from": interproscan_tsv.name,
"description": "Pfam/CDD domain review table for identify member confirmation; default_keep marks selected family domains",
"summary": {
"rows": domain_review_result.get("rows", 0),
"proteins_with_hits": domain_review_result.get("proteins_with_hits", 0),
"default_keep_count": domain_review_result.get("default_keep_count", 0),
"required_domains": required_pfams or [],
},
}
curated_default_id = final_dir / f"{effective_prefix}.curated_members.id"
if curated_default_id.exists():
manifest_data["artifacts"]["curated_members_id"] = {
"artifact": "curated_members_id",
"file": curated_default_id.name,
"full_path": str(curated_default_id.resolve()),
"relpath_or_hint": f"final/{curated_default_id.name}",
"type": "id_list",
"size": curated_default_id.stat().st_size,
"description": "Default curated ID list generated from required family domain hits",
}
curated_default_pep = final_dir / f"{effective_prefix}.curated_members.pep.fa"
if curated_default_pep.exists():
manifest_data["artifacts"]["curated_members"] = {
"artifact": "curated_members",
"file": curated_default_pep.name,
"full_path": str(curated_default_pep.resolve()),
"relpath_or_hint": f"final/{curated_default_pep.name}",
"type": "fasta",
"size": curated_default_pep.stat().st_size,
"description": "Default curated protein FASTA generated from required family domain hits; used by subfamily evidence route before final confirmation",
}
candidate_pep_file = final_dir / f"{effective_prefix}.candidate_members.pep.fa"
candidate_id_file = final_dir / f"{effective_prefix}.candidate_members.id"
if candidate_pep_file.exists():
manifest_data["artifacts"]["candidate_members"] = {
"artifact": "candidate_members",
"file": candidate_pep_file.name,
"full_path": str(candidate_pep_file.resolve()),
"relpath_or_hint": f"final/{candidate_pep_file.name}",
"type": "fasta",
"size": candidate_pep_file.stat().st_size,
"description": "Pre-domain-review candidate member FASTA from HMM/BLAST",
}
if candidate_id_file.exists():
manifest_data["artifacts"]["candidate_members_id"] = {
"artifact": "candidate_members_id",
"file": candidate_id_file.name,
"full_path": str(candidate_id_file.resolve()),
"relpath_or_hint": f"final/{candidate_id_file.name}",
"type": "id_list",
"size": candidate_id_file.stat().st_size,
"description": "Pre-domain-review candidate member IDs from HMM/BLAST",
}
if interproscan_result.get("status") == "success":
manifest_data["artifacts"]["interproscan_domain_check"] = {
"artifact": "interproscan_domain_check",
"file": interproscan_tsv.name,
"full_path": str(interproscan_tsv.resolve()),
"relpath_or_hint": f"final/{interproscan_tsv.name}",
"type": "tsv",
"size": interproscan_tsv.stat().st_size if interproscan_tsv.exists() else 0,
"derived_from": merged_pep_file.name,
"description": "InterProScan TSV for domain-checking identified members",
"summary": interproscan_result.get("summary", {}),
}
else:
manifest_data["warnings"].append({
"step": "interproscan_domain_check",
"status": interproscan_result.get("status"),
"reason": interproscan_result.get("reason", "InterProScan did not produce a TSV")
})
manifest_data["artifacts"]["interproscan_domain_check_status"] = {
"artifact": "interproscan_domain_check_status",
"file": interproscan_status_file.name,
"full_path": str(interproscan_status_file.resolve()),
"relpath_or_hint": f"final/{interproscan_status_file.name}",
"type": "txt",
"size": interproscan_status_file.stat().st_size if interproscan_status_file.exists() else 0,
"status": interproscan_result.get("status"),
"derived_from": merged_pep_file.name,
"description": "Readable InterProScan domain-check status for frontend result review",
}
clean_input_value = interproscan_result.get("clean_input")
clean_input = Path(clean_input_value) if clean_input_value else None
if clean_input and clean_input.exists():
manifest_data["artifacts"]["interproscan_clean_input"] = {
"artifact": "interproscan_clean_input",
"file": clean_input.name,
"full_path": str(clean_input.resolve()),
"relpath_or_hint": f"final/{clean_input.name}",
"type": "fasta",
"size": clean_input.stat().st_size,
"derived_from": merged_pep_file.name,
"description": "Final member FASTA sanitized for InterProScan by removing '*' stop markers",
"sanitize": interproscan_result.get("sanitize", {}),
}
species_manifest_files = []
for r in species_results:
species = r["species"]
canonical_prefix = r.get("canonical_prefix") or species_prefix_for(species, gene_family)
species_manifest = dict(manifest_data)
species_manifest["prefix"] = canonical_prefix
species_manifest["output_prefix"] = canonical_prefix
species_manifest["canonical_member_prefix"] = canonical_prefix
species_manifest["species"] = species
species_manifest["species_role"] = "target"
species_manifest["target_species"] = [species]
species_manifest["selected_species"] = [species]
species_manifest["species_count"] = 1
species_manifest["species_results"] = [r]
species_manifest["legacy_output_prefix"] = r.get("compat_prefix") or f"Target.{species}.{gene_family}"
species_manifest["artifacts"] = dict(manifest_data.get("artifacts") or {})
species_manifest_file = final_dir / f"{canonical_prefix}.identify.manifest.json"
species_manifest_files.append(species_manifest_file)
write_manifest(species_manifest_file, species_manifest)
scope_manifest_file = None
if scope_data.get("scope_id"):
scope_manifest_file = final_dir / f"{effective_prefix}.{sanitize_token(scope_data['scope_id'])}.identify.manifest.json"
manifest_data["scope_manifest"] = str(scope_manifest_file.resolve())
# Write a non-hidden manifest name even when the submitted prefix is empty.
manifest_file = final_dir / f"{effective_prefix}.identify.manifest.json"
write_manifest(manifest_file, manifest_data)
if scope_manifest_file:
write_manifest(scope_manifest_file, manifest_data)
print(f"\n🎉 Identify 阶段完成!")
print(f" 处理物种数: {len(species_results)}")
print(f" 总成员数: {sum(r['final_members'] for r in species_results)}")
print(f" 合并文件: {merged_pep_file}")
for item in single_species_artifacts:
print(f" 单物种主文件: {item['protein_fasta']}")
print(f" 兼容Target文件: {item['compat_protein_fasta']}")
print(f" Manifest: {manifest_file}")
if scope_manifest_file:
print(f" Scope manifest: {scope_manifest_file}")
if __name__ == "__main__":
main()