1041 lines
40 KiB
Python
Executable File
1041 lines
40 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
PEP阶段:蛋白特征分析(完整版)
|
||
|
||
输入:
|
||
- Identify阶段的蛋白序列文件 (final/{prefix}.final_members.pep.fa)
|
||
|
||
输出:
|
||
- final/{prefix}.pep_properties.pdf/png (理化性质图)
|
||
- final/{prefix}.pep_tmhmm.pdf/png (跨膜结构图)
|
||
- final/{prefix}.pep_subcellular.pdf/png (亚细胞定位图)
|
||
- final/{prefix}.pep_report.md (分析报告)
|
||
- final/{prefix}.pep.manifest.json (机器清单)
|
||
"""
|
||
|
||
import os
|
||
import argparse
|
||
import json
|
||
import hashlib
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import shutil
|
||
import re
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List, Any, Optional
|
||
|
||
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
|
||
|
||
CONDA_BIN = "/home/xzq/miniconda3/envs/test4py311/bin"
|
||
if CONDA_BIN not in os.environ.get("PATH", ""):
|
||
os.environ["PATH"] = CONDA_BIN + ":" + os.environ.get("PATH", "")
|
||
|
||
# Plant-mPLoc code is retained, but the default PEP subcellular localization
|
||
# method is WoLF PSORT because it preserves per-protein predictions reliably.
|
||
PLANT_MPLOC_ENABLED = False
|
||
|
||
|
||
def load_manifest(manifest_file: Path) -> Optional[Dict[str, Any]]:
|
||
try:
|
||
return json.loads(manifest_file.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def count_fasta_records(fasta_file: Path) -> int:
|
||
count = 0
|
||
with open(fasta_file, encoding="utf-8", errors="ignore") as handle:
|
||
for line in handle:
|
||
if line.startswith(">"):
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def ensure_protein_fasta_without_stop_markers(protein_fasta: Path) -> Path:
|
||
has_stop = False
|
||
with open(protein_fasta, encoding="utf-8", errors="ignore") as handle:
|
||
for line in handle:
|
||
if not line.startswith(">") and "*" in line:
|
||
has_stop = True
|
||
break
|
||
if not has_stop:
|
||
return protein_fasta
|
||
clean_name = re.sub(r"(\.fa(?:sta)?)$", r".clean\1", protein_fasta.name, flags=re.IGNORECASE)
|
||
if clean_name == protein_fasta.name:
|
||
clean_name = protein_fasta.name + ".clean.fa"
|
||
clean_fasta = protein_fasta.with_name(clean_name)
|
||
if clean_fasta.exists() and clean_fasta.stat().st_mtime >= protein_fasta.stat().st_mtime:
|
||
return clean_fasta
|
||
with open(protein_fasta, "r", encoding="utf-8", errors="ignore") as src, open(clean_fasta, "w", encoding="utf-8") as dst:
|
||
for line in src:
|
||
dst.write(line if line.startswith(">") else line.replace("*", ""))
|
||
print(f"[INFO] Created stop-marker-clean FASTA for PEP: {clean_fasta}")
|
||
return clean_fasta
|
||
|
||
|
||
def resolve_identify_protein_fasta_for_scope(workspace: Path, prefix: str, scope_data: Dict[str, Any]) -> Optional[Path]:
|
||
scope_mode = str((scope_data or {}).get("scope_mode") or "").strip()
|
||
if scope_mode not in {"", "target_only"}:
|
||
return None
|
||
final_dir = workspace / "final"
|
||
family = str((scope_data or {}).get("family") or (prefix.split(".")[-1] if "." in prefix else prefix)).strip()
|
||
targets = [str(x).strip() for x in (scope_data or {}).get("target_species") or [] if str(x).strip()]
|
||
candidates = [final_dir / f"{prefix}.final_members.pep.fa"]
|
||
if len(targets) == 1:
|
||
species = targets[0]
|
||
candidates = [
|
||
workspace / "identify" / species / "final" / f"{species}.final_gfmembers.pep.fa",
|
||
final_dir / f"Target.{species}.{family}.final_members.pep.fa",
|
||
final_dir / f"{species}.{family}.final_members.pep.fa",
|
||
*candidates,
|
||
]
|
||
for candidate in candidates:
|
||
if candidate.exists() and candidate.stat().st_size > 0:
|
||
return ensure_protein_fasta_without_stop_markers(candidate)
|
||
return None
|
||
|
||
|
||
def resolve_scope_protein_fasta(workspace: Path, prefix: str, scope_data: Dict[str, Any], cli_fasta: Optional[str] = None) -> Path:
|
||
if cli_fasta:
|
||
fasta_path = Path(cli_fasta)
|
||
if fasta_path.exists() and fasta_path.stat().st_size > 0:
|
||
return ensure_protein_fasta_without_stop_markers(fasta_path)
|
||
raise FileNotFoundError(f"Specified protein FASTA not found: {cli_fasta}")
|
||
|
||
final_dir = workspace / "final"
|
||
scope_id = str(scope_data.get("scope_id") or "")
|
||
manifest_candidates = []
|
||
if scope_id:
|
||
manifest_candidates.append(final_dir / f"{prefix}.{sanitize_token(scope_id)}.phylogeny.manifest.json")
|
||
manifest_candidates.append(final_dir / f"{prefix}.phylogeny.manifest.json")
|
||
|
||
for manifest_file in manifest_candidates:
|
||
if not manifest_file.exists():
|
||
continue
|
||
manifest_data = load_manifest(manifest_file)
|
||
if not manifest_data:
|
||
continue
|
||
fasta_text = manifest_data.get("parameters", {}).get("scope_input_fasta")
|
||
if fasta_text:
|
||
fasta_path = Path(fasta_text)
|
||
if fasta_path.exists() and fasta_path.stat().st_size > 0:
|
||
print(f"[INFO] Found scoped protein FASTA from phylogeny manifest: {fasta_path}")
|
||
return ensure_protein_fasta_without_stop_markers(fasta_path)
|
||
|
||
fallback = resolve_identify_protein_fasta_for_scope(workspace, prefix, scope_data)
|
||
if fallback:
|
||
print(f"[INFO] Using target-only Identify protein FASTA: {fallback}")
|
||
return fallback
|
||
|
||
raise FileNotFoundError(
|
||
"Cannot find scoped protein FASTA. Run phylogeny with the same scope first, "
|
||
"or provide --fasta explicitly."
|
||
)
|
||
|
||
|
||
def scope_parameters(scope_file: Optional[Path], scope_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||
if not scope_file or not scope_data:
|
||
return {}
|
||
return {
|
||
"scope_file": str(scope_file),
|
||
"scope_id": scope_data.get("scope_id", ""),
|
||
"scope_mode": scope_data.get("scope_mode", ""),
|
||
"target_species": scope_data.get("target_species", []),
|
||
"reference_species": scope_data.get("reference_species", []),
|
||
"comparative_species": scope_data.get("comparative_species", []),
|
||
"selected_species": scope_data.get("selected_species", []),
|
||
"family": scope_data.get("family", ""),
|
||
}
|
||
|
||
|
||
def compute_file_hash(filepath: Path) -> str:
|
||
"""计算文件的SHA256哈希值"""
|
||
sha256_hash = hashlib.sha256()
|
||
try:
|
||
with open(filepath, "rb") as f:
|
||
for byte_block in iter(lambda: f.read(4096), b""):
|
||
sha256_hash.update(byte_block)
|
||
return sha256_hash.hexdigest()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def get_tool_versions() -> Dict[str, str]:
|
||
"""获取工具版本"""
|
||
versions = {}
|
||
|
||
try:
|
||
result = subprocess.run(["R", "--version"], capture_output=True, text=True, check=False, timeout=5)
|
||
versions["R"] = result.stdout.split('\n')[0] if result.stdout else "available"
|
||
except Exception:
|
||
versions["R"] = "not_found"
|
||
|
||
try:
|
||
biolib_bin = "/home/xzq/miniconda3/envs/test4py311/bin/biolib"
|
||
result = subprocess.run([biolib_bin, "--version"], capture_output=True, text=True, check=False, timeout=5)
|
||
versions["biolib"] = "available" if result.returncode == 0 else "not_found"
|
||
except Exception:
|
||
versions["biolib"] = "not_found"
|
||
|
||
return versions
|
||
|
||
|
||
def prepare_directories(workspace: Path) -> Dict[str, Path]:
|
||
"""创建目录"""
|
||
paths = {
|
||
"analysis_dir": workspace / "pep" / "analysis",
|
||
"final_dir": workspace / "final",
|
||
"logs_dir": workspace / "logs",
|
||
}
|
||
for name, path in paths.items():
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return paths
|
||
|
||
|
||
def parse_fasta(fasta_file: Path) -> Dict[str, str]:
|
||
"""解析FASTA文件"""
|
||
sequences = {}
|
||
current_id = None
|
||
current_seq = []
|
||
|
||
with open(fasta_file, 'r') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith('>'):
|
||
if current_id:
|
||
sequences[current_id] = ''.join(current_seq)
|
||
current_id = line[1:].split()[0]
|
||
current_seq = []
|
||
else:
|
||
current_seq.append(line)
|
||
|
||
if current_id:
|
||
sequences[current_id] = ''.join(current_seq)
|
||
|
||
return sequences
|
||
|
||
|
||
def external_tool_timeout(fasta_file: Path, seconds_per_sequence: int = 90, minimum: int = 300, maximum: int = 7200) -> int:
|
||
"""Estimate a conservative timeout for slow web/cloud predictors."""
|
||
try:
|
||
count = sum(1 for line in open(fasta_file, 'r') if line.startswith('>'))
|
||
except Exception:
|
||
count = 1
|
||
return max(minimum, min(maximum, count * seconds_per_sequence))
|
||
|
||
|
||
def write_clean_fasta(fasta_file: Path, output_file: Path) -> Path:
|
||
"""Write DeepTMHMM-friendly FASTA with short IDs and canonical amino acids."""
|
||
allowed = set("ACDEFGHIKLMNPQRSTVWY")
|
||
sequences = parse_fasta(fasta_file)
|
||
with open(output_file, "w") as f:
|
||
for seq_id, seq in sequences.items():
|
||
clean = "".join(aa for aa in seq.upper() if aa in allowed)
|
||
f.write(f">{seq_id}\n{clean}\n")
|
||
return output_file
|
||
|
||
|
||
def run_deeptmhmm_dtu_web(fasta_file: Path, output_dir: Path, timeout_sec: int) -> Dict[str, Any]:
|
||
"""Run DeepTMHMM through the DTU web form when BioLib file staging fails."""
|
||
from selenium import webdriver
|
||
from selenium.webdriver.common.by import By
|
||
from selenium.webdriver.firefox.options import Options
|
||
from selenium.webdriver.firefox.service import Service
|
||
from selenium.webdriver.support import expected_conditions as EC
|
||
from selenium.webdriver.support.ui import WebDriverWait
|
||
|
||
geckodriver = shutil.which("geckodriver")
|
||
if not geckodriver:
|
||
return {"success": False, "stderr": "geckodriver not found"}
|
||
|
||
clean_fasta = write_clean_fasta(fasta_file, output_dir / f"{fasta_file.stem}.deeptmhmm.clean.fa")
|
||
html_file = output_dir / "deeptmhmm_dtu.html"
|
||
topologies = output_dir / "predicted_topologies.3line"
|
||
gff_file = output_dir / "deeptmhmm_results.gff3"
|
||
|
||
options = Options()
|
||
options.add_argument("-headless")
|
||
driver = webdriver.Firefox(service=Service(geckodriver), options=options)
|
||
try:
|
||
driver.get("https://services.healthtech.dtu.dk/services/DeepTMHMM-1.0/")
|
||
WebDriverWait(driver, 30).until(EC.presence_of_element_located((By.NAME, "uploadfile")))
|
||
driver.find_element(By.NAME, "uploadfile").send_keys(str(clean_fasta.resolve()))
|
||
driver.find_element(By.CSS_SELECTOR, 'input[type="submit"]').click()
|
||
start = time.time()
|
||
while time.time() - start < timeout_sec:
|
||
body = driver.find_element(By.TAG_NAME, "body").text
|
||
if "Predicted Topologies" in body or "Predicted topologies" in body:
|
||
html_file.write_text(driver.page_source, encoding="utf-8")
|
||
lines = body.splitlines()
|
||
start_idx = None
|
||
for i, line in enumerate(lines):
|
||
if line.strip() == "Predicted Topologies":
|
||
start_idx = i + 1
|
||
break
|
||
if start_idx is None:
|
||
return {"success": False, "stderr": "Predicted Topologies section not found"}
|
||
topo_lines = []
|
||
gff_lines = []
|
||
in_gff = False
|
||
for line in lines[start_idx:]:
|
||
if line.startswith("##gff-version"):
|
||
in_gff = True
|
||
if line.startswith("DTU Health Tech") or line.startswith("ADDRESS & INFO"):
|
||
break
|
||
if in_gff:
|
||
if line.strip():
|
||
gff_lines.append(line)
|
||
else:
|
||
if line.strip():
|
||
topo_lines.append(line)
|
||
topologies.write_text("\n".join(topo_lines).strip() + "\n", encoding="utf-8")
|
||
gff_file.write_text("\n".join(gff_lines).strip() + "\n", encoding="utf-8")
|
||
return {
|
||
"success": topologies.exists() and topologies.stat().st_size > 0,
|
||
"results_md": html_file,
|
||
"topologies": topologies,
|
||
"gff": gff_file,
|
||
"method": "DTU DeepTMHMM web",
|
||
}
|
||
if "failed" in body.lower() or "error" in body.lower():
|
||
html_file.write_text(driver.page_source, encoding="utf-8")
|
||
return {"success": False, "stderr": body[:500]}
|
||
time.sleep(20)
|
||
html_file.write_text(driver.page_source, encoding="utf-8")
|
||
return {"success": False, "stderr": "DTU DeepTMHMM web job timed out"}
|
||
finally:
|
||
driver.quit()
|
||
|
||
|
||
def calculate_physicochemical_properties(sequences: Dict[str, str]) -> List[Dict]:
|
||
"""计算理化性质"""
|
||
print(f" 计算理化性质...")
|
||
|
||
# 氨基酸分子量
|
||
aa_weights = {
|
||
'A': 89.09, 'R': 174.20, 'N': 132.12, 'D': 133.10, 'C': 121.16,
|
||
'E': 147.13, 'Q': 146.15, 'G': 75.07, 'H': 155.16, 'I': 131.17,
|
||
'L': 131.17, 'K': 146.19, 'M': 149.21, 'F': 165.19, 'P': 115.13,
|
||
'S': 105.09, 'T': 119.12, 'W': 204.23, 'Y': 181.19, 'V': 117.15
|
||
}
|
||
|
||
# 氨基酸电荷
|
||
aa_charge = {
|
||
'D': -1, 'E': -1, 'K': 1, 'R': 1, 'H': 0.5
|
||
}
|
||
|
||
properties = []
|
||
|
||
for seq_id, sequence in sequences.items():
|
||
seq = sequence.upper()
|
||
length = len(seq)
|
||
|
||
# 分子量
|
||
mw = sum(aa_weights.get(aa, 110) for aa in seq) - (length - 1) * 18.02
|
||
|
||
# 等电点估算
|
||
acidic = seq.count('D') + seq.count('E')
|
||
basic = seq.count('K') + seq.count('R') + seq.count('H')
|
||
if basic > acidic:
|
||
pI = 7.0 + (basic - acidic) * 0.5
|
||
elif acidic > basic:
|
||
pI = 7.0 - (acidic - basic) * 0.3
|
||
else:
|
||
pI = 7.0
|
||
|
||
# 疏水性 (简化的GRAVY)
|
||
hydrophobic_aa = 'AILMFWV'
|
||
hydrophilic_aa = 'RNDQEK'
|
||
hydrophobic_count = sum(seq.count(aa) for aa in hydrophobic_aa)
|
||
hydrophilic_count = sum(seq.count(aa) for aa in hydrophilic_aa)
|
||
gravy = (hydrophobic_count - hydrophilic_count) / length if length > 0 else 0
|
||
|
||
# 脂肪族指数
|
||
aliphatic = (seq.count('A') + seq.count('V') + seq.count('I') + seq.count('L')) / length * 100
|
||
|
||
properties.append({
|
||
"id": seq_id,
|
||
"length": length,
|
||
"molecular_weight": mw,
|
||
"isoelectric_point": pI,
|
||
"gravy": gravy,
|
||
"aliphatic_index": aliphatic
|
||
})
|
||
|
||
return properties
|
||
|
||
|
||
def run_deeptmhmm(fasta_file: Path, output_dir: Path) -> Dict[str, Any]:
|
||
"""运行DeepTMHMM跨膜预测"""
|
||
print(f" 运行DeepTMHMM...")
|
||
|
||
results_md = output_dir / "deeptmhmm_results.md"
|
||
topologies = output_dir / "predicted_topologies.3line"
|
||
for old_file in (results_md, topologies):
|
||
if old_file.exists():
|
||
old_file.unlink()
|
||
timeout_sec = external_tool_timeout(fasta_file, seconds_per_sequence=120, minimum=900, maximum=10800)
|
||
|
||
web_result = run_deeptmhmm_dtu_web(fasta_file, output_dir, timeout_sec)
|
||
if web_result.get("success"):
|
||
return web_result
|
||
print(f" DTU网页DeepTMHMM失败,尝试BioLib: {web_result.get('stderr', '')[:200]}")
|
||
|
||
try:
|
||
biolib_bin = "/home/xzq/miniconda3/envs/test4py311/bin/biolib"
|
||
cmd = [biolib_bin, "run", "DTU/DeepTMHMM", "--fasta", str(fasta_file)]
|
||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||
cwd=str(output_dir), timeout=timeout_sec)
|
||
|
||
if result.returncode != 0 or not topologies.exists():
|
||
stderr = result.stderr[:300] if result.stderr else "DeepTMHMM did not generate topology output"
|
||
print(f" DeepTMHMM未生成有效结果: {stderr}")
|
||
print(" 尝试通过DTU官方网页运行DeepTMHMM...")
|
||
return run_deeptmhmm_dtu_web(fasta_file, output_dir, timeout_sec)
|
||
|
||
return {
|
||
"success": True,
|
||
"results_md": results_md,
|
||
"topologies": topologies
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f" DeepTMHMM错误: {e}")
|
||
return {"success": False, "stderr": str(e)}
|
||
|
||
|
||
def run_plant_mploc(fasta_file: Path, output_file: Path) -> Dict[str, Any]:
|
||
"""运行Plant-mPLoc"""
|
||
print(f" 运行Plant-mPLoc...")
|
||
|
||
try:
|
||
script = Path("scripts/get_Plant_mPLoc_out.py")
|
||
|
||
if script.exists():
|
||
cmd = [sys.executable, str(script), "--input", str(fasta_file), "--output", str(output_file)]
|
||
if output_file.exists():
|
||
output_file.unlink()
|
||
started_at = time.time()
|
||
timeout_sec = external_tool_timeout(fasta_file, seconds_per_sequence=90)
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
|
||
if result.returncode != 0:
|
||
return {"success": False, "stderr": result.stderr or result.stdout}
|
||
else:
|
||
return {"success": False, "stderr": f"Plant-mPLoc script not found: {script}"}
|
||
|
||
if not output_file.exists() or output_file.stat().st_size == 0 or output_file.stat().st_mtime < started_at:
|
||
return {"success": False, "stderr": "Plant-mPLoc did not generate output"}
|
||
|
||
return {"success": True, "output_file": output_file, "method": "Plant-mPLoc website automation"}
|
||
|
||
except Exception as e:
|
||
print(f" Plant-mPLoc错误: {e}")
|
||
return {"success": False, "stderr": str(e)}
|
||
|
||
|
||
def run_wolfpsort(fasta_file: Path, output_file: Path) -> Dict[str, Any]:
|
||
"""运行WoLF PSORT"""
|
||
print(f" 运行WoLF PSORT...")
|
||
|
||
try:
|
||
script = Path("scripts/wolfpsort_batch.py")
|
||
|
||
if script.exists():
|
||
cmd = [sys.executable, str(script), "--input", str(fasta_file), "--output", str(output_file)]
|
||
if output_file.exists():
|
||
output_file.unlink()
|
||
started_at = time.time()
|
||
timeout_sec = external_tool_timeout(fasta_file, seconds_per_sequence=90)
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
|
||
if result.returncode != 0:
|
||
return {"success": False, "stderr": result.stderr or result.stdout}
|
||
else:
|
||
return {"success": False, "stderr": f"WoLF PSORT script not found: {script}"}
|
||
|
||
if not output_file.exists() or output_file.stat().st_size == 0 or output_file.stat().st_mtime < started_at:
|
||
return {"success": False, "stderr": "WoLF PSORT did not generate output"}
|
||
|
||
return {"success": True, "output_file": output_file, "method": "WoLF PSORT website automation"}
|
||
|
||
except Exception as e:
|
||
print(f" WoLF PSORT错误: {e}")
|
||
return {"success": False, "stderr": str(e)}
|
||
|
||
|
||
def plot_properties_r(properties: List[Dict], output_pdf: Path, output_png: Path) -> bool:
|
||
"""使用R绘制理化性质图"""
|
||
print(f" 绘制理化性质图...")
|
||
|
||
# 准备数据文件
|
||
data_file = output_pdf.parent / "properties_data.tsv"
|
||
with open(data_file, 'w') as f:
|
||
f.write("id\tlength\tmw\tpI\tgravy\taliphatic\n")
|
||
for p in properties:
|
||
f.write(f"{p['id']}\t{p['length']}\t{p['molecular_weight']:.2f}\t"
|
||
f"{p['isoelectric_point']:.2f}\t{p['gravy']:.3f}\t{p['aliphatic_index']:.2f}\n")
|
||
|
||
r_script = f'''
|
||
library(ggplot2)
|
||
library(patchwork)
|
||
|
||
data <- read.delim("{data_file}", sep="\t", header=TRUE)
|
||
|
||
# 分子量分布
|
||
p1 <- ggplot(data, aes(x=mw/1000)) +
|
||
geom_histogram(bins=15, fill="steelblue", color="white") +
|
||
labs(title="Molecular Weight Distribution",
|
||
x="Molecular Weight (kDa)", y="Count") +
|
||
theme_minimal()
|
||
|
||
# 等电点分布
|
||
p2 <- ggplot(data, aes(x=pI)) +
|
||
geom_histogram(bins=15, fill="coral", color="white") +
|
||
labs(title="Isoelectric Point Distribution", x="pI", y="Count") +
|
||
theme_minimal()
|
||
|
||
# 序列长度分布
|
||
p3 <- ggplot(data, aes(x=length)) +
|
||
geom_histogram(bins=15, fill="seagreen", color="white") +
|
||
labs(title="Sequence Length Distribution", x="Length (aa)", y="Count") +
|
||
theme_minimal()
|
||
|
||
# 疏水性分布
|
||
p4 <- ggplot(data, aes(x=gravy)) +
|
||
geom_histogram(bins=15, fill="purple", color="white") +
|
||
labs(title="GRAVY (Hydrophobicity)", x="GRAVY Score", y="Count") +
|
||
theme_minimal()
|
||
|
||
combined <- (p1 + p2) / (p3 + p4)
|
||
|
||
ggsave("{output_pdf}", combined, width=12, height=10)
|
||
|
||
png("{output_png}", width=1200, height=1000, res=150)
|
||
print(combined)
|
||
dev.off()
|
||
'''
|
||
|
||
r_file = output_pdf.parent / "plot_properties.R"
|
||
with open(r_file, 'w') as f:
|
||
f.write(r_script)
|
||
|
||
try:
|
||
rscript = "/home/xzq/miniconda3/envs/test4py311/bin/Rscript"
|
||
result = subprocess.run([rscript, str(r_file)],
|
||
capture_output=True, text=True, timeout=120)
|
||
if result.returncode != 0:
|
||
print(f" R绘图错误: {result.stderr[:300]}")
|
||
return output_pdf.exists() and output_png.exists()
|
||
except Exception as e:
|
||
print(f" R绘图错误: {e}")
|
||
return False
|
||
|
||
|
||
def plot_tmhmm_results(topologies_file: Path, output_pdf: Path, output_png: Path) -> bool:
|
||
"""绘制跨膜结构图"""
|
||
print(f" 绘制跨膜结构图...")
|
||
|
||
# 解析拓扑结构
|
||
transmembrane_count = {}
|
||
|
||
if topologies_file.exists():
|
||
with open(topologies_file, 'r') as f:
|
||
current_id = None
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith('>'):
|
||
current_id = line[1:]
|
||
elif current_id and line and not line.startswith('>'):
|
||
# 统计M(跨膜)的数量
|
||
tm_count = line.count('M')
|
||
transmembrane_count[current_id] = tm_count
|
||
current_id = None
|
||
|
||
# 准备数据
|
||
data_file = output_pdf.parent / "tmhmm_data.tsv"
|
||
with open(data_file, 'w') as f:
|
||
f.write("id\ttm_count\n")
|
||
for seq_id, count in transmembrane_count.items():
|
||
f.write(f"{seq_id}\t{count}\n")
|
||
|
||
r_script = f'''
|
||
library(ggplot2)
|
||
|
||
data <- read.delim("{data_file}", sep="\t", header=TRUE)
|
||
|
||
# 跨膜螺旋数量分布
|
||
p <- ggplot(data, aes(x=tm_count)) +
|
||
geom_bar(fill="darkblue", color="white") +
|
||
labs(title="Transmembrane Helices Distribution",
|
||
x="Number of TM Helices", y="Count") +
|
||
theme_minimal()
|
||
|
||
ggsave("{output_pdf}", p, width=8, height=6)
|
||
|
||
png("{output_png}", width=800, height=600, res=150)
|
||
print(p)
|
||
dev.off()
|
||
'''
|
||
|
||
r_file = output_pdf.parent / "plot_tmhmm.R"
|
||
with open(r_file, 'w') as f:
|
||
f.write(r_script)
|
||
|
||
try:
|
||
rscript = "/home/xzq/miniconda3/envs/test4py311/bin/Rscript"
|
||
result = subprocess.run([rscript, str(r_file)],
|
||
capture_output=True, text=True, timeout=120)
|
||
if result.returncode != 0:
|
||
print(f" R绘图错误: {result.stderr[:300]}")
|
||
return output_pdf.exists() and output_png.exists()
|
||
except Exception as e:
|
||
print(f" R绘图错误: {e}")
|
||
return False
|
||
|
||
|
||
|
||
LOCATION_LABELS = {
|
||
"nucl": "Nucleus",
|
||
"nucleus": "Nucleus",
|
||
"cyto": "Cytoplasm",
|
||
"cytoplasm": "Cytoplasm",
|
||
"chlo": "Chloroplast",
|
||
"chloroplast": "Chloroplast",
|
||
"mito": "Mitochondrion",
|
||
"mitochondrion": "Mitochondrion",
|
||
"plas": "Plasma membrane",
|
||
"plasma membrane": "Plasma membrane",
|
||
"vacu": "Vacuole",
|
||
"vacuole": "Vacuole",
|
||
"golg": "Golgi apparatus",
|
||
"golgi": "Golgi apparatus",
|
||
"extr": "Extracellular",
|
||
"extracellular": "Extracellular",
|
||
"pero": "Peroxisome",
|
||
"peroxisome": "Peroxisome",
|
||
"e.r.": "Endoplasmic reticulum",
|
||
"er": "Endoplasmic reticulum",
|
||
"cysk": "Cytoskeleton",
|
||
"cytoskeleton": "Cytoskeleton",
|
||
}
|
||
|
||
|
||
def normalize_location_label(label: str) -> str:
|
||
clean = re.sub(r"\s+", " ", str(label or "").strip())
|
||
if not clean:
|
||
return ""
|
||
key = clean.lower().replace("_", " ")
|
||
if key in LOCATION_LABELS:
|
||
return LOCATION_LABELS[key]
|
||
if " " not in clean and "_" in clean:
|
||
parts = [normalize_location_label(part) for part in clean.split("_")]
|
||
parts = [part for part in parts if part]
|
||
if parts:
|
||
return " / ".join(parts)
|
||
return clean[:1].upper() + clean[1:]
|
||
|
||
|
||
def parse_plant_mploc_counts(plant_mploc_file: Path) -> tuple[Dict[str, int], int]:
|
||
counts: Dict[str, int] = {}
|
||
rows = 0
|
||
if not plant_mploc_file.exists() or plant_mploc_file.stat().st_size == 0:
|
||
return counts, rows
|
||
with open(plant_mploc_file, "r", encoding="utf-8", errors="ignore") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
parts = line.split("\t")
|
||
if len(parts) < 2:
|
||
continue
|
||
first = parts[0].strip().lower()
|
||
second = parts[1].strip()
|
||
if first in {"protein_id", "protein", "id", "gene_id", "sequence_id"}:
|
||
continue
|
||
loc = normalize_location_label(second)
|
||
if not loc:
|
||
continue
|
||
counts[loc] = counts.get(loc, 0) + 1
|
||
rows += 1
|
||
return counts, rows
|
||
|
||
|
||
def parse_wolfpsort_counts(wolfpsort_file: Path) -> tuple[Dict[str, int], int]:
|
||
counts: Dict[str, int] = {}
|
||
rows = 0
|
||
if not wolfpsort_file.exists() or wolfpsort_file.stat().st_size == 0:
|
||
return counts, rows
|
||
with open(wolfpsort_file, "r", encoding="utf-8", errors="ignore") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
parts = line.split("\t")
|
||
if len(parts) < 2 or parts[0].strip().lower() in {"protein_id", "protein", "id"}:
|
||
continue
|
||
prediction = parts[1].strip()
|
||
if not prediction:
|
||
continue
|
||
primary = prediction.split(",", 1)[0]
|
||
primary = primary.split(":", 1)[0].strip()
|
||
loc = normalize_location_label(primary)
|
||
if not loc:
|
||
continue
|
||
counts[loc] = counts.get(loc, 0) + 1
|
||
rows += 1
|
||
return counts, rows
|
||
|
||
|
||
def resolve_subcellular_counts(plant_mploc_file: Path, wolfpsort_file: Path,
|
||
allow_plant_fallback: bool = False) -> tuple[Dict[str, int], str, int]:
|
||
wolf_counts, wolf_rows = parse_wolfpsort_counts(wolfpsort_file)
|
||
if wolf_rows:
|
||
return wolf_counts, "WoLF PSORT", wolf_rows
|
||
|
||
if allow_plant_fallback:
|
||
plant_counts, plant_rows = parse_plant_mploc_counts(plant_mploc_file)
|
||
if plant_rows:
|
||
return plant_counts, "Plant-mPLoc", plant_rows
|
||
|
||
return {}, "WoLF PSORT", 0
|
||
|
||
def plot_subcellular_results(plant_mploc_file: Path, wolfpsort_file: Path,
|
||
output_pdf: Path, output_png: Path) -> bool:
|
||
"""绘制亚细胞定位图"""
|
||
print(f" 绘制亚细胞定位图...")
|
||
|
||
location_counts, source, parsed_rows = resolve_subcellular_counts(plant_mploc_file, wolfpsort_file)
|
||
if not location_counts:
|
||
print(" 亚细胞定位结果为空,跳过绘图")
|
||
return False
|
||
|
||
# 准备数据
|
||
data_file = output_pdf.parent / "subcellular_data.tsv"
|
||
with open(data_file, 'w') as f:
|
||
f.write("location\tcount\tsource\n")
|
||
for loc, count in sorted(location_counts.items(), key=lambda item: item[1], reverse=True):
|
||
f.write(f"{loc}\t{count}\t{source}\n")
|
||
|
||
r_script = f'''
|
||
library(ggplot2)
|
||
|
||
data <- read.delim("{data_file}", sep="\t", header=TRUE)
|
||
|
||
# 按count排序
|
||
data$location <- factor(data$location, levels=data$location[order(data$count, decreasing=TRUE)])
|
||
plot_source <- unique(data$source)[1]
|
||
|
||
p <- ggplot(data, aes(x=location, y=count, fill=location)) +
|
||
geom_bar(stat="identity", width=0.55, color="white") +
|
||
geom_text(aes(label=count), vjust=-0.3, size=4) +
|
||
labs(title=paste0("Subcellular Localization (", plot_source, ")"),
|
||
x="Location", y="Count") +
|
||
theme_minimal() +
|
||
theme(axis.text.x=element_text(angle=45, hjust=1), legend.position="none")
|
||
|
||
ggsave("{output_pdf}", p, width=8, height=6)
|
||
|
||
png("{output_png}", width=800, height=600, res=150)
|
||
print(p)
|
||
dev.off()
|
||
'''
|
||
|
||
r_file = output_pdf.parent / "plot_subcellular.R"
|
||
with open(r_file, 'w') as f:
|
||
f.write(r_script)
|
||
|
||
try:
|
||
rscript = "/home/xzq/miniconda3/envs/test4py311/bin/Rscript"
|
||
result = subprocess.run([rscript, str(r_file)],
|
||
capture_output=True, text=True, timeout=120)
|
||
if result.returncode != 0:
|
||
print(f" R绘图错误: {result.stderr[:300]}")
|
||
return output_pdf.exists() and output_png.exists()
|
||
except Exception as e:
|
||
print(f" R绘图错误: {e}")
|
||
return False
|
||
|
||
def write_report(workspace: Path, prefix: str, metrics: Dict, parameters: Dict,
|
||
tool_versions: Dict) -> Path:
|
||
"""写入报告"""
|
||
report_path = workspace / "final" / f"{prefix}.pep_report.md"
|
||
|
||
with open(report_path, "w") as f:
|
||
f.write(f"# 蛋白特征分析报告 - {prefix}\n\n")
|
||
f.write(f"**生成时间**: {datetime.now().isoformat()}\n\n")
|
||
|
||
f.write("## 1. 分析概述\n")
|
||
f.write("- **分析阶段**: PEP (Protein Features)\n")
|
||
f.write("- **分析内容**: \n")
|
||
f.write(" - 理化性质 (分子量、等电点、疏水性)\n")
|
||
f.write(" - 跨膜结构预测 (DeepTMHMM)\n")
|
||
f.write(" - 亚细胞定位 (WoLF PSORT;Plant-mPLoc代码保留但默认关闭)\n\n")
|
||
|
||
if "protein_count" in metrics:
|
||
f.write(f"- **蛋白数量**: {metrics['protein_count']}\n\n")
|
||
|
||
subcellular_data = workspace / "final" / "subcellular_data.tsv"
|
||
subcellular_rows = []
|
||
if subcellular_data.exists() and subcellular_data.stat().st_size > 0:
|
||
with open(subcellular_data, "r", encoding="utf-8", errors="ignore") as sf:
|
||
header = sf.readline().strip().split("\t")
|
||
for line in sf:
|
||
parts = line.rstrip("\n").split("\t")
|
||
if len(parts) < 2:
|
||
continue
|
||
row = dict(zip(header, parts))
|
||
subcellular_rows.append(row)
|
||
if subcellular_rows:
|
||
f.write("## 2. 亚细胞定位数量\n")
|
||
f.write("| Location | Count | Source |\n")
|
||
f.write("|---|---:|---|\n")
|
||
for row in subcellular_rows:
|
||
f.write(
|
||
f"| {row.get('location', '')} | {row.get('count', '')} | "
|
||
f"{row.get('source', '')} |\n"
|
||
)
|
||
f.write("\n")
|
||
|
||
f.write("## 3. 工具版本\n")
|
||
for tool, version in tool_versions.items():
|
||
f.write(f"- **{tool}**: {version}\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 4. 输出文件\n")
|
||
f.write(f"- `{prefix}.pep_properties.pdf/png` - 理化性质分布图\n")
|
||
f.write(f"- `{prefix}.pep_tmhmm.pdf/png` - 跨膜结构预测图\n")
|
||
f.write(f"- `{prefix}.pep_subcellular.pdf/png` - 亚细胞定位图\n")
|
||
f.write("\n")
|
||
|
||
return report_path
|
||
|
||
|
||
def write_manifest(workspace: Path, job_id: str, run_id: str, prefix: str,
|
||
start_time: datetime, end_time: datetime, artifacts: Dict,
|
||
metrics: Dict, parameters: Dict, tool_versions: Dict) -> Path:
|
||
"""写入manifest"""
|
||
elapsed_sec = (end_time - start_time).total_seconds()
|
||
|
||
manifest_data = {
|
||
"manifest_version": "1.0",
|
||
"job_id": job_id,
|
||
"stage": "pep",
|
||
"run_id": run_id,
|
||
"prefix": prefix,
|
||
"timestamp": end_time.isoformat(),
|
||
"status": "success",
|
||
"elapsed_sec": round(elapsed_sec, 2),
|
||
"tool_versions": tool_versions,
|
||
"artifacts": artifacts,
|
||
"parameters": parameters,
|
||
"metrics": metrics
|
||
}
|
||
|
||
final_dir = workspace / "final"
|
||
final_dir.mkdir(parents=True, exist_ok=True)
|
||
for key in (
|
||
"scope_id",
|
||
"scope_mode",
|
||
"scope_file",
|
||
"target_species",
|
||
"reference_species",
|
||
"comparative_species",
|
||
"selected_species",
|
||
"family",
|
||
):
|
||
if key in parameters:
|
||
manifest_data[key] = parameters[key]
|
||
|
||
manifest_path = final_dir / f"{prefix}.pep.manifest.json"
|
||
with open(manifest_path, "w", encoding='utf-8') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
|
||
if parameters.get("scope_id"):
|
||
scoped_manifest = final_dir / f"{prefix}.{sanitize_token(str(parameters['scope_id']))}.pep.manifest.json"
|
||
manifest_data["scope_manifest"] = str(scoped_manifest.resolve())
|
||
with open(scoped_manifest, "w", encoding="utf-8") as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
|
||
return manifest_path
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="蛋白特征分析")
|
||
parser.add_argument("--prefix", required=True)
|
||
parser.add_argument("--workspace", required=True)
|
||
parser.add_argument("--input-dir")
|
||
parser.add_argument("--fasta", help="蛋白FASTA文件;用于显式指定PEP输入")
|
||
parser.add_argument("--scope-file", help="target/reference/comparative scope JSON")
|
||
parser.add_argument("--cpu", type=int, default=8)
|
||
parser.add_argument("--log-file", help="日志文件路径(用于前端兼容)")
|
||
args = parser.parse_args()
|
||
|
||
start_time = datetime.now()
|
||
run_id = f"pep_{start_time.strftime('%Y%m%d_%H%M%S')}"
|
||
|
||
workspace = Path(args.workspace)
|
||
job_id = workspace.name
|
||
|
||
print("=" * 60)
|
||
print(f"🚀 蛋白特征分析")
|
||
print("=" * 60)
|
||
|
||
try:
|
||
scope_file = Path(args.scope_file) if args.scope_file else None
|
||
scope_data = None
|
||
if scope_file:
|
||
if not scope_file.exists():
|
||
raise FileNotFoundError(f"Scope file not found: {scope_file}")
|
||
scope_data = read_scope(scope_file)
|
||
|
||
# 1. 准备目录
|
||
print("\n📁 准备目录...")
|
||
paths = prepare_directories(workspace)
|
||
|
||
# 2. 获取工具版本
|
||
print("\n🔧 检查工具...")
|
||
tool_versions = get_tool_versions()
|
||
|
||
# 3. 查找输入文件
|
||
if scope_data:
|
||
protein_file = resolve_scope_protein_fasta(workspace, args.prefix, scope_data, args.fasta)
|
||
elif args.fasta:
|
||
protein_file = Path(args.fasta)
|
||
if not protein_file.exists() or protein_file.stat().st_size == 0:
|
||
print(f"❌ 找不到蛋白文件: {protein_file}")
|
||
return 1
|
||
protein_file = ensure_protein_fasta_without_stop_markers(protein_file)
|
||
else:
|
||
protein_file = workspace / "final" / f"{args.prefix}.final_members.pep.fa"
|
||
if not protein_file.exists() or protein_file.stat().st_size == 0:
|
||
print(f"❌ 找不到蛋白文件: {protein_file}")
|
||
return 1
|
||
protein_file = ensure_protein_fasta_without_stop_markers(protein_file)
|
||
|
||
print(f" 蛋白文件: {protein_file}")
|
||
|
||
# 解析序列
|
||
sequences = parse_fasta(protein_file)
|
||
print(f" 包含 {len(sequences)} 个序列")
|
||
|
||
artifacts = {}
|
||
metrics = {"protein_count": len(sequences)}
|
||
|
||
# 4. 理化性质分析
|
||
print("\n🧮 理化性质分析...")
|
||
properties = calculate_physicochemical_properties(sequences)
|
||
|
||
# 绘制理化性质图
|
||
props_pdf = paths["final_dir"] / f"{args.prefix}.pep_properties.pdf"
|
||
props_png = paths["final_dir"] / f"{args.prefix}.pep_properties.png"
|
||
|
||
if plot_properties_r(properties, props_pdf, props_png):
|
||
print(f" ✓ 理化性质图: {props_pdf}, {props_png}")
|
||
artifacts["properties_pdf"] = {"path": f"final/{args.prefix}.pep_properties.pdf"}
|
||
artifacts["properties_png"] = {"path": f"final/{args.prefix}.pep_properties.png"}
|
||
|
||
# 5. 跨膜结构预测
|
||
print("\n🌊 跨膜结构预测 (DeepTMHMM)...")
|
||
tmhmm_result = run_deeptmhmm(protein_file, paths["analysis_dir"])
|
||
|
||
metrics["tmhmm_success"] = bool(tmhmm_result.get("success"))
|
||
if not tmhmm_result.get("success"):
|
||
metrics["tmhmm_error"] = str(tmhmm_result.get("stderr") or tmhmm_result.get("error") or "DeepTMHMM unavailable")[:500]
|
||
if tmhmm_result["success"]:
|
||
# 绘制跨膜结构图
|
||
tmhmm_pdf = paths["final_dir"] / f"{args.prefix}.pep_tmhmm.pdf"
|
||
tmhmm_png = paths["final_dir"] / f"{args.prefix}.pep_tmhmm.png"
|
||
|
||
if plot_tmhmm_results(tmhmm_result["topologies"], tmhmm_pdf, tmhmm_png):
|
||
print(f" ✓ 跨膜结构图: {tmhmm_pdf}, {tmhmm_png}")
|
||
artifacts["tmhmm_pdf"] = {"path": f"final/{args.prefix}.pep_tmhmm.pdf"}
|
||
artifacts["tmhmm_png"] = {"path": f"final/{args.prefix}.pep_tmhmm.png"}
|
||
|
||
# 6. 亚细胞定位预测
|
||
print("\n🎯 亚细胞定位预测...")
|
||
|
||
# Plant-mPLoc code is retained but disabled by default.
|
||
plant_mploc_file = paths["analysis_dir"] / f"{args.prefix}_plant_mploc.txt"
|
||
if PLANT_MPLOC_ENABLED:
|
||
plant_result = run_plant_mploc(protein_file, plant_mploc_file)
|
||
else:
|
||
print(" Plant-mPLoc已关闭;亚细胞定位默认使用WoLF PSORT")
|
||
plant_result = {
|
||
"success": False,
|
||
"disabled": True,
|
||
"method": "Plant-mPLoc website automation",
|
||
"stderr": "Plant-mPLoc disabled; WoLF PSORT is the configured subcellular localization method",
|
||
}
|
||
|
||
# WoLF PSORT
|
||
wolfpsort_file = paths["analysis_dir"] / f"{args.prefix}_wolfpsort.txt"
|
||
wolf_result = run_wolfpsort(protein_file, wolfpsort_file)
|
||
metrics["subcellular_method"] = "WoLF PSORT"
|
||
metrics["plant_mploc_enabled"] = PLANT_MPLOC_ENABLED
|
||
metrics["plant_mploc_success"] = bool(plant_result.get("success"))
|
||
metrics["wolfpsort_success"] = bool(wolf_result.get("success"))
|
||
if plant_result.get("disabled"):
|
||
metrics["plant_mploc_status"] = "disabled"
|
||
elif not plant_result.get("success"):
|
||
metrics["plant_mploc_error"] = str(plant_result.get("stderr") or plant_result.get("error") or "Plant-mPLoc unavailable")[:500]
|
||
if not wolf_result.get("success"):
|
||
metrics["wolfpsort_error"] = str(wolf_result.get("stderr") or wolf_result.get("error") or "WoLF PSORT unavailable")[:500]
|
||
|
||
# 绘制亚细胞定位图;默认只接受WoLF PSORT逐蛋白预测作为数量来源。
|
||
if wolf_result["success"]:
|
||
subcell_pdf = paths["final_dir"] / f"{args.prefix}.pep_subcellular.pdf"
|
||
subcell_png = paths["final_dir"] / f"{args.prefix}.pep_subcellular.png"
|
||
|
||
if plot_subcellular_results(plant_mploc_file, wolfpsort_file, subcell_pdf, subcell_png):
|
||
sub_counts, sub_source, sub_rows = resolve_subcellular_counts(plant_mploc_file, wolfpsort_file)
|
||
metrics["subcellular_source"] = sub_source
|
||
metrics["subcellular_prediction_count"] = sub_rows
|
||
metrics["subcellular_counts"] = sub_counts
|
||
print(f" ✓ 亚细胞定位图: {subcell_pdf}, {subcell_png}")
|
||
artifacts["subcellular_pdf"] = {"path": f"final/{args.prefix}.pep_subcellular.pdf"}
|
||
artifacts["subcellular_png"] = {"path": f"final/{args.prefix}.pep_subcellular.png"}
|
||
artifacts["subcellular_data"] = {"path": "final/subcellular_data.tsv"}
|
||
|
||
# 7. 生成报告
|
||
end_time = datetime.now()
|
||
|
||
print("\n📝 生成报告...")
|
||
parameters = {
|
||
"prefix": args.prefix,
|
||
"cpu": args.cpu,
|
||
"protein_fasta": str(protein_file),
|
||
"protein_count": count_fasta_records(protein_file),
|
||
}
|
||
parameters.update(scope_parameters(scope_file, scope_data))
|
||
|
||
report_path = write_report(workspace, args.prefix, metrics, parameters, tool_versions)
|
||
artifacts["report"] = {"path": f"final/{args.prefix}.pep_report.md"}
|
||
|
||
# 8. 生成manifest
|
||
print("\n📋 生成Manifest...")
|
||
manifest_path = write_manifest(
|
||
workspace, job_id, run_id, args.prefix,
|
||
start_time, end_time, artifacts, metrics, parameters, tool_versions
|
||
)
|
||
|
||
print("\n" + "=" * 60)
|
||
print(f"✅ 蛋白特征分析完成!")
|
||
print("=" * 60)
|
||
|
||
return 0
|
||
|
||
except Exception as e:
|
||
print(f"\n❌ 失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|