forked from admin/PlantAI-rag-new
683 lines
28 KiB
Python
Executable File
683 lines
28 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Combo Plot Stage - Tree + Motif + Structure + Domain 组合图
|
||
|
||
将多个分析阶段的结果整合到一张图中:
|
||
- Panel A: Phylogenetic tree
|
||
- Panel B: Subfamily
|
||
- Panel C: Motif
|
||
- Panel D: Gene structure
|
||
- Panel E: Domain
|
||
"""
|
||
|
||
import os
|
||
import argparse
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
# 项目根目录
|
||
PROJECT_ROOT = Path(__file__).parent.parent.parent.absolute()
|
||
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
|
||
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, scope_hash
|
||
from gfa_core.gene_family_stage_context import build_stage_context, user_output_prefix_for, write_legacy_manifest_copy
|
||
|
||
# 镜像标准形态: 当前解释器即环境解释器; Rscript 由 PATH 查找。
|
||
RSCRIPT_PATH = str(shutil.which("Rscript") or "Rscript")
|
||
CONDA_PREFIX = Path(sys.executable).resolve().parent.parent
|
||
CONDA_BIN = str(CONDA_PREFIX / "bin")
|
||
def find_file_by_glob(pattern: Path, name: str) -> Optional[Path]:
|
||
"""递归查找文件"""
|
||
matches = list(pattern.rglob(name))
|
||
return matches[0] if matches else None
|
||
|
||
|
||
def find_meme_xml(workspace: Path, prefix: str) -> Optional[Path]:
|
||
"""查找 MEME XML 文件"""
|
||
# 优先在 motif/analysis/meme_out/ 中查找
|
||
meme_dir = workspace / "motif" / "analysis" / "meme_out"
|
||
if meme_dir.exists():
|
||
xml_file = meme_dir / "meme.xml"
|
||
if xml_file.exists():
|
||
return xml_file
|
||
|
||
# 备选:递归搜索
|
||
return find_file_by_glob(workspace, "meme.xml")
|
||
|
||
|
||
def resolve_meme_xml_from_manifest(workspace: Path, motif_data: Dict, prefix: str, manifest_path: Path) -> Optional[Path]:
|
||
artifacts = motif_data.get("artifacts", {}) or {}
|
||
meme_xml = artifacts.get("meme_xml") if isinstance(artifacts, dict) else None
|
||
candidates = []
|
||
if isinstance(meme_xml, dict):
|
||
path = manifest_artifact_path(workspace, manifest_path, meme_xml)
|
||
if path:
|
||
candidates.append(path)
|
||
|
||
meme_results = artifacts.get("meme_results") if isinstance(artifacts, dict) else None
|
||
if isinstance(meme_results, dict):
|
||
path = manifest_artifact_path(workspace, manifest_path, meme_results)
|
||
if path:
|
||
candidates.append(path / "meme.xml")
|
||
|
||
motif_txt = artifacts.get("motif_prot_txt") if isinstance(artifacts, dict) else None
|
||
if isinstance(motif_txt, dict):
|
||
path = manifest_artifact_path(workspace, manifest_path, motif_txt)
|
||
if path:
|
||
candidates.append(path.parent / "meme_out" / "meme.xml")
|
||
|
||
for candidate in candidates:
|
||
if candidate.exists() and candidate.stat().st_size > 0:
|
||
return candidate.absolute()
|
||
return find_meme_xml(workspace, prefix)
|
||
|
||
|
||
def load_manifest(manifest_path: Path) -> Dict:
|
||
"""加载 manifest 文件"""
|
||
if not manifest_path.exists():
|
||
raise FileNotFoundError(f"Manifest not found: {manifest_path}")
|
||
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def motif_manifest_failure_message(motif_data: Dict) -> Optional[str]:
|
||
status = str((motif_data or {}).get("status") or "").strip().lower()
|
||
if status in {"", "success", "partial_success"}:
|
||
return None
|
||
params = motif_data.get("parameters") if isinstance(motif_data, dict) else {}
|
||
params = params if isinstance(params, dict) else {}
|
||
detail = (
|
||
motif_data.get("error")
|
||
or motif_data.get("message")
|
||
or params.get("error")
|
||
or params.get("message")
|
||
or status
|
||
)
|
||
return f"Motif upstream failed: {detail}"
|
||
|
||
|
||
def manifest_artifact_path(workspace: Path, manifest_path: Path, artifact: Dict) -> Optional[Path]:
|
||
if not isinstance(artifact, dict):
|
||
return None
|
||
input_dir = workspace.parent.parent / "input" / workspace.name
|
||
|
||
def inside_current_job(path: Path) -> bool:
|
||
try:
|
||
resolved = path.resolve()
|
||
return resolved.is_relative_to(workspace.resolve()) or resolved.is_relative_to(input_dir.resolve())
|
||
except OSError:
|
||
return False
|
||
|
||
def rebase_current_job_path(path: Path) -> Optional[Path]:
|
||
parts = path.parts
|
||
for root_name, current_root in (("workspace", workspace), ("input", input_dir)):
|
||
for idx, part in enumerate(parts):
|
||
if part != root_name:
|
||
continue
|
||
if idx + 1 >= len(parts) or parts[idx + 1] != workspace.name:
|
||
continue
|
||
suffix = Path(*parts[idx + 2 :]) if idx + 2 < len(parts) else Path()
|
||
candidate = current_root / suffix
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
for key in ("path", "relpath_or_hint", "file", "full_path"):
|
||
raw = str(artifact.get(key) or "").strip()
|
||
if not raw:
|
||
continue
|
||
path = Path(raw)
|
||
if path.is_absolute():
|
||
if inside_current_job(path):
|
||
return path
|
||
rebased = rebase_current_job_path(path)
|
||
if rebased:
|
||
return rebased
|
||
continue
|
||
candidates = []
|
||
if str(path).startswith("final/"):
|
||
candidates.append(workspace / path)
|
||
candidates.append(manifest_path.parent / path.name)
|
||
else:
|
||
candidates.append(workspace / path)
|
||
candidates.append(manifest_path.parent / path)
|
||
candidates.append(manifest_path.parent / path.name)
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def materialize_aggregate_subfamily_file(workspace: Path, prefix: str, scope_data: Optional[Dict]) -> Optional[Path]:
|
||
"""Build a combined subfamily table from per-target reference-classification manifests."""
|
||
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
|
||
if not scope_id:
|
||
return None
|
||
final_dir = workspace / "final"
|
||
aggregate_manifest = final_dir / f"{prefix}.{sanitize_token(scope_id)}.reference_classification.manifest.json"
|
||
if not aggregate_manifest.exists():
|
||
return None
|
||
try:
|
||
aggregate = load_manifest(aggregate_manifest)
|
||
except Exception:
|
||
return None
|
||
rows = []
|
||
for artifact in (aggregate.get("artifacts") or {}).values():
|
||
child_manifest = manifest_artifact_path(workspace, aggregate_manifest, artifact)
|
||
if not child_manifest or not child_manifest.exists() or not child_manifest.name.endswith(".manifest.json"):
|
||
continue
|
||
try:
|
||
child = load_manifest(child_manifest)
|
||
except Exception:
|
||
continue
|
||
child_artifacts = child.get("artifacts") or {}
|
||
sub_artifact = child_artifacts.get("subfamily_file") or child_artifacts.get("geneID_subfamily_compat")
|
||
sub_path = manifest_artifact_path(workspace, child_manifest, sub_artifact)
|
||
if not sub_path or not sub_path.exists():
|
||
continue
|
||
for raw in sub_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||
line = raw.strip()
|
||
if not line or line.lower().startswith("gene"):
|
||
continue
|
||
rows.append(line)
|
||
if not rows:
|
||
return None
|
||
out = final_dir / f"{prefix}.{sanitize_token(scope_id)}.reference_classification.subfamily.tsv"
|
||
seen = set()
|
||
with out.open("w", encoding="utf-8") as handle:
|
||
for line in rows:
|
||
key = line.split("\t", 1)[0] if "\t" in line else line.split(None, 1)[0]
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
handle.write(line + "\n")
|
||
print(f"[INFO] Materialized aggregate subfamily file: {out}")
|
||
return out
|
||
|
||
|
||
def find_latest_manifest(workspace: Path, stage: str, prefix: str, scope_data: Optional[Dict] = None) -> Optional[Path]:
|
||
"""查找最新的 manifest 文件"""
|
||
final_dir = workspace / "final"
|
||
if not final_dir.exists():
|
||
return None
|
||
|
||
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
|
||
if scope_data and scope_data.get("scope_id"):
|
||
scoped = final_dir / f"{prefix}.{sanitize_token(scope_id)}.{stage}.manifest.json"
|
||
if scoped.exists():
|
||
return scoped
|
||
compact = final_dir / f"{compact_output_prefix(prefix, scope_data)}.{stage}.manifest.json"
|
||
if compact.exists():
|
||
return compact
|
||
|
||
pattern = f"*{stage}*.manifest.json"
|
||
candidates = list(final_dir.glob(pattern))
|
||
if scope_id:
|
||
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
|
||
if scope_id in path.name:
|
||
return path
|
||
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
if data.get("stage") == stage and data.get("scope_id") == scope_id:
|
||
return path
|
||
|
||
# 按修改时间排序,返回最新的
|
||
if candidates:
|
||
return max(candidates, key=lambda p: p.stat().st_mtime)
|
||
return None
|
||
|
||
|
||
def compact_output_prefix(prefix: str, scope_data: Optional[Dict]) -> str:
|
||
"""Return a bounded user-facing output prefix.
|
||
|
||
Full scope details stay in scope JSON and manifest. Result filenames should
|
||
not enumerate every target/reference/comparative species.
|
||
"""
|
||
if not scope_data:
|
||
return prefix
|
||
family = str(scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip()
|
||
mode = str(scope_data.get("scope_mode") or "").strip()
|
||
targets = [str(x) for x in scope_data.get("target_species") or [] if str(x).strip()]
|
||
scope_id = str(scope_data.get("scope_id") or "").strip()
|
||
if len(targets) == 1 and family:
|
||
return ".".join(x for x in [targets[0], family, mode] if x)
|
||
if mode:
|
||
base = prefix if prefix else f"Target.{family}".strip(".")
|
||
if scope_id:
|
||
return f"{base}.{mode}.h{scope_hash(scope_id, 6)}"
|
||
return f"{base}.{mode}"
|
||
return prefix
|
||
|
||
|
||
def sanitize_output_label(label: str) -> str:
|
||
value = str(label or "").strip()
|
||
if not value:
|
||
return ""
|
||
safe = []
|
||
for ch in value:
|
||
if ch.isalnum() or ch in {"_", "-", "."}:
|
||
safe.append(ch)
|
||
else:
|
||
safe.append("_")
|
||
cleaned = "".join(safe).strip("._-")
|
||
return cleaned[:80]
|
||
|
||
|
||
def normalize_panels(raw_panels: str) -> List[str]:
|
||
allowed = ["motif", "structure", "domain"]
|
||
aliases = {
|
||
"gene_structure": "structure",
|
||
"gene-structure": "structure",
|
||
"genestructure": "structure",
|
||
"domains": "domain",
|
||
"motifs": "motif",
|
||
"all": "all",
|
||
}
|
||
selected: List[str] = []
|
||
for token in str(raw_panels or "").replace(";", ",").split(","):
|
||
key = token.strip().lower().replace(" ", "_")
|
||
if not key:
|
||
continue
|
||
key = aliases.get(key, key)
|
||
if key == "all":
|
||
return allowed
|
||
if key not in allowed:
|
||
raise ValueError(f"Unsupported combo panel: {token}. Allowed: {', '.join(allowed)}")
|
||
if key not in selected:
|
||
selected.append(key)
|
||
selected = selected or allowed
|
||
if len(selected) < 2:
|
||
raise ValueError("Combo plot requires at least two data panels; single-panel views already exist in motif/structure/domain stages.")
|
||
return selected
|
||
|
||
|
||
def run_combo_plot(
|
||
workspace: Path,
|
||
prefix: str,
|
||
output_prefix: str,
|
||
tree_file: Path,
|
||
subfamily_file: Optional[Path],
|
||
meme_xml: Optional[Path],
|
||
gff3_file: Optional[Path],
|
||
domtblout_file: Optional[Path],
|
||
panels: List[str],
|
||
output_label: str = "",
|
||
evalue_cutoff: float = 1e-5,
|
||
coverage_percent: Optional[float] = None,
|
||
rscript: Optional[Path] = None,
|
||
) -> Dict:
|
||
"""
|
||
运行 R 脚本绘制组合图
|
||
"""
|
||
if rscript is None:
|
||
rscript = SCRIPTS_DIR / "combo_tree_motif_structure_domain.R"
|
||
|
||
if not rscript.exists():
|
||
return {
|
||
"success": False,
|
||
"error": f"R script not found: {rscript}"
|
||
}
|
||
|
||
# 输出文件。修订图使用独立 stem,避免覆盖标准 combo 结果。
|
||
label = sanitize_output_label(output_label)
|
||
output_stem = f"{output_prefix}.{label}" if label else output_prefix
|
||
output_pdf = workspace / "final" / f"{output_stem}.combo.pdf"
|
||
|
||
# 构建命令
|
||
cmd = [
|
||
str(RSCRIPT_PATH), str(rscript),
|
||
str(tree_file),
|
||
str(subfamily_file) if subfamily_file else "NONE", # 传递 NONE 表示无亚家族
|
||
str(meme_xml) if meme_xml else "NONE",
|
||
str(gff3_file) if gff3_file else "NONE",
|
||
str(domtblout_file) if domtblout_file else "NONE",
|
||
str(evalue_cutoff), # E-value cutoff
|
||
]
|
||
|
||
# 添加 coverage(可选)
|
||
if coverage_percent is not None:
|
||
cmd.append(str(coverage_percent))
|
||
else:
|
||
cmd.append("0") # 不启用 coverage 过滤
|
||
|
||
# 添加输出文件和 panel 选择
|
||
cmd.append(str(output_pdf))
|
||
cmd.append(",".join(panels))
|
||
|
||
print(f"[CMD] {' '.join(cmd)}")
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=300
|
||
)
|
||
|
||
output_png = output_pdf.with_suffix(".png")
|
||
success = result.returncode == 0 and output_pdf.exists()
|
||
if not success and not result.stderr:
|
||
result.stderr = result.stdout
|
||
|
||
return {
|
||
"success": success,
|
||
"output_pdf": str(output_pdf) if success else None,
|
||
"output_png": str(output_png) if success and output_png.exists() else None,
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-2000:] if result.stdout else "",
|
||
"stderr": result.stderr[-2000:] if result.stderr else "",
|
||
}
|
||
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
def promote_scoped_combo_outputs(workspace: Path, prefix: str, scope_id: str, result: Dict, output_label: str = "") -> Dict:
|
||
"""Materialize legacy scoped combo copies without changing canonical outputs."""
|
||
if not scope_id or not result.get("success"):
|
||
return result
|
||
scoped_prefix = f"{prefix}.{sanitize_token(scope_id)}"
|
||
label = sanitize_output_label(output_label)
|
||
scoped_stem = f"{scoped_prefix}.{label}" if label else scoped_prefix
|
||
final_dir = workspace / "final"
|
||
promoted = dict(result)
|
||
for key, suffix in (("output_pdf", ".combo.pdf"), ("output_png", ".combo.png")):
|
||
source_text = promoted.get(key)
|
||
if not source_text:
|
||
continue
|
||
source = Path(source_text)
|
||
if not source.exists():
|
||
continue
|
||
target = final_dir / f"{scoped_stem}{suffix}"
|
||
if source.resolve() != target.resolve():
|
||
shutil.copy2(source, target)
|
||
promoted[f"legacy_{key}"] = str(target)
|
||
return promoted
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Combo Plot: Tree + Motif + Structure + Domain"
|
||
)
|
||
parser.add_argument("--prefix", required=True, help="Analysis prefix (e.g., Ft.SPL)")
|
||
parser.add_argument("--workspace", required=True, help="Workspace directory")
|
||
parser.add_argument("--evalue", type=float, default=1e-5, help="E-value cutoff")
|
||
parser.add_argument("--coverage", type=float, default=None, help="Coverage percent (0-100)")
|
||
parser.add_argument("--subfamily", type=str, default=None, help="Subfamily file path")
|
||
parser.add_argument("--scope-file", type=str, default=None, help="Gene-family scope JSON from resolve_scope.py")
|
||
parser.add_argument("--analysis-route", type=str, default="", help="Analysis route identity, e.g. single_species/subfamily_prediction")
|
||
parser.add_argument("--member-set-id", type=str, default="", help="Member set identity for scoped outputs")
|
||
parser.add_argument("--cpu", type=int, default=8, help="CPU cores (unused, for compatibility)")
|
||
parser.add_argument("--run-id", type=str, default=None, help="Run ID")
|
||
parser.add_argument("--replot", action="store_true", help="Replot even if exists")
|
||
parser.add_argument("--panels", type=str, default="motif,structure,domain", help="Comma-separated panels to show; choose at least two from motif,structure,domain")
|
||
parser.add_argument("--output-label", type=str, default="", help="Optional revision label inserted before .combo; keeps standard combo outputs from being overwritten")
|
||
|
||
args = parser.parse_args()
|
||
|
||
workspace = Path(args.workspace)
|
||
prefix = args.prefix
|
||
try:
|
||
panels = normalize_panels(args.panels)
|
||
except ValueError as exc:
|
||
print(f"[ERROR] {exc}")
|
||
sys.exit(1)
|
||
output_label = sanitize_output_label(args.output_label)
|
||
scope_data: Optional[Dict] = None
|
||
scope_file: Optional[Path] = None
|
||
if args.scope_file:
|
||
scope_file = Path(args.scope_file)
|
||
if not scope_file.exists():
|
||
print(f"[ERROR] Scope file not found: {args.scope_file}")
|
||
sys.exit(1)
|
||
scope_data = read_scope(scope_file)
|
||
|
||
print(f"[INFO] Combo plot stage for {prefix}")
|
||
print(f"[INFO] Workspace: {workspace}")
|
||
print(f"[INFO] Panels: {', '.join(panels)}")
|
||
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
|
||
output_prefix = user_output_prefix_for(
|
||
"combo",
|
||
prefix,
|
||
scope_data or {},
|
||
analysis_route=args.analysis_route,
|
||
member_set_id=args.member_set_id,
|
||
) if scope_id else prefix
|
||
if output_prefix != prefix:
|
||
print(f"[INFO] Output prefix: {output_prefix}")
|
||
|
||
# 1. 查找 tree 文件
|
||
phylo_manifest = find_latest_manifest(workspace, "phylogeny", prefix, scope_data)
|
||
if not phylo_manifest:
|
||
print("[ERROR] Phylogeny manifest not found")
|
||
sys.exit(1)
|
||
|
||
phylo_data = load_manifest(phylo_manifest)
|
||
tree_file = manifest_artifact_path(workspace, phylo_manifest, phylo_data.get("artifacts", {}).get("tree", {}))
|
||
if not tree_file or not tree_file.exists():
|
||
print("[ERROR] Tree file not found")
|
||
sys.exit(1)
|
||
print(f"[INFO] Tree file: {tree_file}")
|
||
|
||
# 2. 查找 subfamily 文件
|
||
if args.subfamily:
|
||
subfamily_file = Path(args.subfamily)
|
||
else:
|
||
# 自动查找只接受 confirmed_subfamily;prediction/geneID 必须由 --subfamily 显式传入。
|
||
confirmed_name = f"{prefix}.confirmed_subfamily.tsv"
|
||
scoped_confirmed_name = f"{output_prefix}.confirmed_subfamily.tsv"
|
||
search_paths = [
|
||
workspace / "final" / scoped_confirmed_name,
|
||
workspace / "final" / confirmed_name,
|
||
workspace / "input" / confirmed_name,
|
||
workspace.parent.parent / "input" / workspace.name / confirmed_name,
|
||
]
|
||
if scope_data and scope_data.get("scope_mode") in {"target_only", "target_plus_reference"}:
|
||
family = str(scope_data.get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip()
|
||
targets = [str(x) for x in scope_data.get("target_species") or [] if str(x).strip()]
|
||
refs = [str(x) for x in scope_data.get("reference_species") or [] if str(x).strip()]
|
||
if len(targets) == 1 and family:
|
||
cross_prefix = f"{targets[0]}.{family}.target_plus_reference"
|
||
scoped_prediction = None
|
||
if refs:
|
||
scoped_prediction = f"Target.{family}.target-{targets[0]}.ref-{'-'.join(refs)}.subfamily_prediction"
|
||
extra_paths = [
|
||
workspace / "final" / f"{cross_prefix}.confirmed_subfamily.tsv",
|
||
]
|
||
if scoped_prediction:
|
||
extra_paths.extend([
|
||
workspace / "final" / f"{scoped_prediction}.confirmed_subfamily.tsv",
|
||
])
|
||
search_paths[0:0] = extra_paths
|
||
subfamily_file = None
|
||
for p in search_paths:
|
||
if p.exists():
|
||
subfamily_file = p
|
||
break
|
||
# Do not materialize aggregate prediction labels during automatic lookup.
|
||
# Unconfirmed prediction tables require an explicit --subfamily path.
|
||
|
||
if not subfamily_file or not subfamily_file.exists():
|
||
print("[INFO] Subfamily file not found, will run without subfamily coloring")
|
||
subfamily_file = None
|
||
else:
|
||
print(f"[INFO] Subfamily file: {subfamily_file}")
|
||
|
||
# 3. 查找所选 panel 需要的输入文件
|
||
meme_xml: Optional[Path] = None
|
||
if "motif" in panels:
|
||
motif_manifest = find_latest_manifest(workspace, "motif", prefix, scope_data)
|
||
if not motif_manifest:
|
||
print("[ERROR] Motif manifest not found")
|
||
sys.exit(1)
|
||
motif_data = load_manifest(motif_manifest)
|
||
motif_failure = motif_manifest_failure_message(motif_data)
|
||
if motif_failure:
|
||
print(f"[ERROR] {motif_failure}")
|
||
sys.exit(1)
|
||
meme_xml = resolve_meme_xml_from_manifest(workspace, motif_data, prefix, motif_manifest)
|
||
if not meme_xml or not meme_xml.exists():
|
||
print("[ERROR] MEME XML not found")
|
||
sys.exit(1)
|
||
print(f"[INFO] MEME XML: {meme_xml}")
|
||
|
||
gff3_file: Optional[Path] = None
|
||
if "structure" in panels:
|
||
struct_manifest = find_latest_manifest(workspace, "structure", prefix, scope_data)
|
||
if not struct_manifest:
|
||
print("[ERROR] Structure manifest not found")
|
||
sys.exit(1)
|
||
struct_data = load_manifest(struct_manifest)
|
||
gff3_file = manifest_artifact_path(workspace, struct_manifest, struct_data.get("artifacts", {}).get("structure_gff3_filtered", {}))
|
||
if not gff3_file or not gff3_file.exists():
|
||
print("[ERROR] Structure filtered GFF3 not found")
|
||
sys.exit(1)
|
||
print(f"[INFO] GFF3 file: {gff3_file}")
|
||
|
||
domtblout_file: Optional[Path] = None
|
||
if "domain" in panels:
|
||
domain_manifest = find_latest_manifest(workspace, "domain", prefix, scope_data)
|
||
if not domain_manifest:
|
||
print("[ERROR] Domain manifest not found")
|
||
sys.exit(1)
|
||
domain_data = load_manifest(domain_manifest)
|
||
domtblout_file = manifest_artifact_path(workspace, domain_manifest, domain_data.get("artifacts", {}).get("domtblout", {}))
|
||
if not domtblout_file or not domtblout_file.exists():
|
||
print("[ERROR] Domain domtblout not found")
|
||
sys.exit(1)
|
||
print(f"[INFO] domtblout file: {domtblout_file}")
|
||
|
||
# 6. 运行组合图
|
||
result = run_combo_plot(
|
||
workspace=workspace,
|
||
prefix=prefix,
|
||
output_prefix=output_prefix,
|
||
tree_file=tree_file,
|
||
subfamily_file=subfamily_file,
|
||
meme_xml=meme_xml,
|
||
gff3_file=gff3_file,
|
||
domtblout_file=domtblout_file,
|
||
panels=panels,
|
||
output_label=output_label,
|
||
evalue_cutoff=args.evalue,
|
||
coverage_percent=args.coverage,
|
||
)
|
||
result = promote_scoped_combo_outputs(workspace, prefix, scope_id, result, output_label=output_label)
|
||
|
||
if result.get("success"):
|
||
print(f"[OK] Combo plot generated: {result['output_pdf']}")
|
||
|
||
# 检查 PNG 是否存在
|
||
png_path = result.get("output_png")
|
||
png_exists = png_path and Path(png_path).exists() if png_path else False
|
||
|
||
# 生成 manifest
|
||
artifact_base = "combo_replot" if output_label else "combo_plot"
|
||
artifacts = {
|
||
f"{artifact_base}_pdf": {
|
||
"artifact": artifact_base,
|
||
"path": str(Path(result["output_pdf"]).relative_to(workspace)),
|
||
"full_path": result["output_pdf"],
|
||
"type": "image",
|
||
"format": "pdf",
|
||
"description": f"Tree + {' + '.join(p.title() for p in panels)} combo visualization"
|
||
}
|
||
}
|
||
|
||
if png_exists:
|
||
artifacts[f"{artifact_base}_png"] = {
|
||
"artifact": artifact_base,
|
||
"path": str(Path(png_path).relative_to(workspace)),
|
||
"full_path": png_path,
|
||
"type": "image",
|
||
"format": "png",
|
||
"description": f"Tree + {' + '.join(p.title() for p in panels)} combo visualization (PNG)"
|
||
}
|
||
|
||
stage_context = build_stage_context(
|
||
stage="combo",
|
||
prefix=prefix,
|
||
scope_data=scope_data or {},
|
||
scope_file=str(scope_file or ""),
|
||
output_prefix=output_prefix,
|
||
subfamily_file=str(subfamily_file or ""),
|
||
analysis_route=args.analysis_route or "",
|
||
member_set_id=args.member_set_id or "",
|
||
).to_dict()
|
||
manifest = {
|
||
"manifest_version": "1.0",
|
||
"job_id": workspace.name,
|
||
"stage": "combo",
|
||
"run_id": args.run_id or "unknown",
|
||
"prefix": prefix,
|
||
"output_prefix": output_prefix,
|
||
"status": "success",
|
||
"analysis_route": stage_context.get("analysis_route", ""),
|
||
"result_group": stage_context.get("result_group", ""),
|
||
"member_set_id": stage_context.get("member_set_id", ""),
|
||
"subfamily_source": stage_context.get("subfamily_source", ""),
|
||
"stage_context": stage_context,
|
||
"artifacts": artifacts,
|
||
"parameters": {
|
||
"evalue": args.evalue,
|
||
"coverage": args.coverage,
|
||
"output_prefix": output_prefix,
|
||
"output_label": output_label,
|
||
"subfamily_file": str(subfamily_file),
|
||
"tree_file": str(tree_file),
|
||
"meme_xml": str(meme_xml or ""),
|
||
"gff3_file": str(gff3_file or ""),
|
||
"domtblout_file": str(domtblout_file or ""),
|
||
"panels": panels,
|
||
"analysis_route": stage_context.get("analysis_route", ""),
|
||
"member_set_id": stage_context.get("member_set_id", ""),
|
||
"subfamily_source": stage_context.get("subfamily_source", ""),
|
||
}
|
||
}
|
||
if scope_data:
|
||
manifest["scope_id"] = scope_data.get("scope_id", "")
|
||
manifest["scope_mode"] = scope_data.get("scope_mode", "")
|
||
manifest["scope_file"] = str(scope_file)
|
||
manifest["target_species"] = scope_data.get("target_species", [])
|
||
manifest["reference_species"] = scope_data.get("reference_species", [])
|
||
manifest["comparative_species"] = scope_data.get("comparative_species", [])
|
||
manifest["selected_species"] = scope_data.get("selected_species", [])
|
||
manifest["scope"] = {
|
||
"family": scope_data.get("family", ""),
|
||
"prefix": scope_data.get("prefix", ""),
|
||
"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", []),
|
||
}
|
||
|
||
manifest_stem = f"{output_prefix}.{output_label}" if output_label else output_prefix
|
||
manifest_path = workspace / "final" / f"{manifest_stem}.combo.manifest.json"
|
||
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
|
||
print(f"[INFO] Manifest: {manifest_path}")
|
||
if scope_data and scope_data.get("scope_id") and not output_label:
|
||
scoped_manifest = write_legacy_manifest_copy(manifest_path, prefix, str(scope_data.get("scope_id") or ""), "combo")
|
||
if scoped_manifest:
|
||
print(f"[INFO] Legacy scoped manifest: {scoped_manifest}")
|
||
|
||
else:
|
||
print(f"[ERROR] Combo plot failed: {result.get('error', result.get('stderr', 'unknown'))}")
|
||
if result.get("stdout"):
|
||
print(result["stdout"])
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|