1248 lines
47 KiB
Python
Executable File
1248 lines
47 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Expression阶段:基因表达量分析与可视化
|
||
|
||
输入:
|
||
- expression_file: 表达量矩阵文件 (如 TPM/FPKM 矩阵)
|
||
- gene_id_file: 基因ID列表文件
|
||
- sample_file: 样本信息文件
|
||
|
||
输出:
|
||
- final/{prefix}.expression.manifest.json (机器清单)
|
||
- final/{prefix}.expression_report.md (分析报告)
|
||
- final/{prefix}*.pdf (表达量图 PDF)
|
||
- final/{prefix}*.png (表达量图 PNG)
|
||
|
||
依赖:
|
||
- R环境 (conda env: rnaR)
|
||
- pdftoppm (用于PDF转PNG)
|
||
- 上游阶段: Identify (需要 final_members.id)
|
||
|
||
示例:
|
||
python run_expression.py --prefix Ft.SPL \\
|
||
--workspace /path/to/workspace \\
|
||
--expression-file expression_matrix.txt \\
|
||
--gene-id-file gene_ids.txt \\
|
||
--sample-file sample_info.txt
|
||
"""
|
||
|
||
import os
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import platform
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List, Any, Optional
|
||
|
||
PROJECT_ROOT = Path(__file__).absolute().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 (
|
||
apply_stage_context,
|
||
build_stage_context,
|
||
infer_analysis_route,
|
||
member_set_id_for,
|
||
)
|
||
from gfa_core.rnaseq_context import apply_rnaseq_context, build_rnaseq_context
|
||
from cli.rnaseq_env import prepend_path, rnaseq_rscript, rnar_bin, rnar_rscript
|
||
|
||
CONDA_BIN = str(rnar_bin())
|
||
prepend_path(CONDA_BIN)
|
||
|
||
|
||
def scope_parameters(
|
||
scope_file: Optional[Path],
|
||
scope_data: Optional[Dict[str, Any]],
|
||
analysis_route: str = "",
|
||
member_set_id: str = "",
|
||
) -> Dict[str, Any]:
|
||
if not scope_file or not scope_data:
|
||
return {}
|
||
params = {
|
||
"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", ""),
|
||
}
|
||
if analysis_route:
|
||
params["analysis_route"] = analysis_route
|
||
if member_set_id:
|
||
params["member_set_id"] = member_set_id
|
||
return params
|
||
|
||
def get_tool_versions() -> Dict[str, str]:
|
||
"""获取工具版本信息"""
|
||
versions = {}
|
||
|
||
# R版本
|
||
try:
|
||
result = subprocess.run(
|
||
["R", "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=5
|
||
)
|
||
if result.returncode == 0:
|
||
lines = result.stdout.split('\n')
|
||
for line in lines:
|
||
if "R version" in line:
|
||
versions["R"] = line.strip()
|
||
break
|
||
if "R" not in versions:
|
||
versions["R"] = lines[0] if lines else "unknown"
|
||
except Exception as e:
|
||
versions["R"] = f"error: {str(e)[:50]}"
|
||
|
||
# pdftoppm版本
|
||
try:
|
||
result = subprocess.run(
|
||
["pdftoppm", "-v"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=5
|
||
)
|
||
output = result.stdout.strip() or result.stderr.strip()
|
||
if output:
|
||
versions["pdftoppm"] = output.split('\n')[0]
|
||
else:
|
||
versions["pdftoppm"] = "available"
|
||
except Exception as e:
|
||
versions["pdftoppm"] = f"error: {str(e)[:50]}"
|
||
|
||
# Python版本
|
||
versions["python"] = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||
|
||
return versions
|
||
|
||
|
||
def compute_file_hash(filepath: Path) -> str:
|
||
"""计算文件的SHA256哈希值"""
|
||
sha256_hash = hashlib.sha256()
|
||
try:
|
||
with open(filepath, "rb") as f:
|
||
for byte_block in iter(lambda: f.read(4096), b""):
|
||
sha256_hash.update(byte_block)
|
||
return sha256_hash.hexdigest()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def abs_text(path: Optional[Path]) -> str:
|
||
return str(Path(path).absolute()) if path else ""
|
||
|
||
|
||
def parse_metric_tsv(path: Path) -> Dict[str, str]:
|
||
metrics: Dict[str, str] = {}
|
||
if not path.exists():
|
||
return metrics
|
||
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
|
||
header = handle.readline()
|
||
for line in handle:
|
||
if not line.strip() or "\t" not in line:
|
||
continue
|
||
key, value = line.rstrip("\n").split("\t", 1)
|
||
metrics[key] = value
|
||
return metrics
|
||
|
||
|
||
def truthy_metric(value: Any) -> bool:
|
||
return str(value).strip().lower() in {"1", "true", "yes", "pass"}
|
||
|
||
|
||
def resolve_rnaseq_expression_matrices(
|
||
final_dir: Path,
|
||
prefix: str,
|
||
fpkm_file: Optional[Path] = None,
|
||
tpm_file: Optional[Path] = None,
|
||
vst_file: Optional[Path] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Resolve RNA-seq expression matrices from quant bridge QC.
|
||
|
||
FPKM remains featureCounts-derived. TPM is switched to Salmon/Kallisto
|
||
tximport only when rnaseq_quant matrix_bridge_qc explicitly passes.
|
||
"""
|
||
fpkm = fpkm_file or final_dir / f"{prefix}.fpkm_matrix.txt"
|
||
featurecounts_tpm = tpm_file or final_dir / f"{prefix}.tpm_matrix.txt"
|
||
vst = vst_file or final_dir / f"{prefix}.rnaseq_sample_qc.vst_matrix.tsv"
|
||
qc_file = final_dir / f"{prefix}.rnaseq_quant.matrix_bridge_qc.tsv"
|
||
qc = parse_metric_tsv(qc_file)
|
||
status_pass = qc.get("status") == "pass"
|
||
source = qc.get("recommended_expression_tpm_source", "")
|
||
engine = source.split("_", 1)[0] if source.endswith("_tximport_tpm") else ""
|
||
candidate = final_dir / f"{prefix}.{engine}.tpm_matrix.txt" if engine else featurecounts_tpm
|
||
|
||
if status_pass and engine in {"salmon", "kallisto", "rsem"} and candidate.exists():
|
||
chosen_tpm = candidate
|
||
tpm_source = source
|
||
reason = f"bridge_qc_passed:{qc_file.name}"
|
||
else:
|
||
chosen_tpm = featurecounts_tpm
|
||
tpm_source = "featurecounts_derived_tpm"
|
||
if not qc:
|
||
reason = "matrix_bridge_qc_missing"
|
||
elif not status_pass:
|
||
reason = "bridge_qc_not_pass:" + (qc.get("failure_reasons") or qc.get("status", "unknown"))
|
||
elif not candidate.exists():
|
||
reason = f"bridge_qc_candidate_missing:{candidate.name}"
|
||
else:
|
||
reason = "featurecounts_tpm_requested"
|
||
|
||
return {
|
||
"fpkm_file": fpkm,
|
||
"tpm_file": chosen_tpm,
|
||
"vst_file": vst,
|
||
"tpm_source": tpm_source,
|
||
"tpm_source_reason": reason,
|
||
"matrix_bridge_qc_file": qc_file if qc_file.exists() else None,
|
||
"matrix_bridge_qc": qc,
|
||
"display_evidence_policy": "DE heatmaps use VST from featureCounts/DESeq2; abundance distributions and family TPM plots use tximport TPM only after bridge QC passes.",
|
||
}
|
||
|
||
|
||
def validate_previous_stage(workspace: Path, prefix: str, gene_id_override: Optional[Path] = None) -> Dict[str, Any]:
|
||
"""
|
||
验证Identify阶段的输出文件并获取必要信息
|
||
|
||
Args:
|
||
workspace: 工作空间目录(workspace/<job_id>)
|
||
prefix: 分析前缀,如 Ft.SPL
|
||
|
||
Returns:
|
||
包含输入文件路径和Identify manifest数据的字典
|
||
|
||
Raises:
|
||
FileNotFoundError: Identify输出不存在
|
||
ValueError: 文件无效或格式错误
|
||
"""
|
||
print(f"正在验证Identify阶段输出: {workspace}")
|
||
|
||
# 1. 查找Identify的manifest
|
||
identify_manifest_path = workspace / "final" / f"{prefix}.identify.manifest.json"
|
||
if not identify_manifest_path.exists():
|
||
raise FileNotFoundError(f"Identify manifest未找到: {identify_manifest_path}")
|
||
|
||
print(f" 找到Identify manifest: {identify_manifest_path}")
|
||
|
||
# 2. 读取Identify manifest
|
||
with open(identify_manifest_path, 'r') as f:
|
||
identify_manifest = json.load(f)
|
||
|
||
# 3. 验证Identify状态
|
||
if identify_manifest.get("status") != "success":
|
||
raise ValueError(f"Identify阶段未成功完成,状态: {identify_manifest.get('status')}")
|
||
|
||
# 4. 查找基因ID文件
|
||
final_members_id = gene_id_override if gene_id_override else workspace / "final" / f"{prefix}.final_members.id"
|
||
if not final_members_id.exists():
|
||
raise FileNotFoundError(f"基因ID文件未找到: {final_members_id}")
|
||
|
||
print(f" 找到基因ID文件: {final_members_id}")
|
||
|
||
# 读取基因数量
|
||
with open(final_members_id, 'r') as f:
|
||
gene_count = len([line for line in f if line.strip()])
|
||
|
||
print(f" 基因数量: {gene_count}")
|
||
|
||
return {
|
||
"identify_manifest": identify_manifest,
|
||
"final_members_id": final_members_id,
|
||
"gene_count": gene_count,
|
||
"identify_inputs": identify_manifest.get("inputs", [])
|
||
}
|
||
|
||
|
||
def prepare_directories(workspace: Path) -> Dict[str, Path]:
|
||
"""创建Expression阶段的标准目录结构"""
|
||
paths = {
|
||
"analysis_dir": workspace / "expression" / "analysis",
|
||
"final_dir": workspace / "final",
|
||
"logs_dir": workspace / "logs",
|
||
}
|
||
|
||
print("创建目录结构:")
|
||
for name, path in paths.items():
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
print(f" {name}: {path}")
|
||
|
||
return paths
|
||
|
||
|
||
|
||
def run_rnaseq_auto_expression(
|
||
workspace: Path,
|
||
prefix: str,
|
||
job_id: str,
|
||
run_id: str,
|
||
fpkm_file: Path,
|
||
tpm_file: Path,
|
||
vst_file: Path,
|
||
sample_table: Path,
|
||
de_dir: Path,
|
||
count_matrix: Optional[Path] = None,
|
||
family_members: Optional[Path] = None,
|
||
family_label: str = "",
|
||
top_n: int = 100,
|
||
time_top_n: int = 12,
|
||
triggered_by: str = "rnaseq_de",
|
||
) -> Path:
|
||
"""Run RNA-seq-derived expression plots without requiring Identify.
|
||
|
||
This mode is the automatic post-processing path after rnaseq_de. The
|
||
legacy expression mode below remains available for user-supplied FPKM/TPM
|
||
matrices and explicit gene lists.
|
||
"""
|
||
final_dir = workspace / "final"
|
||
logs_dir = workspace / "logs"
|
||
final_dir.mkdir(parents=True, exist_ok=True)
|
||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||
script = PROJECT_ROOT / "scripts" / "draw_rnaseq_expression.R"
|
||
if not script.exists():
|
||
raise FileNotFoundError(f"RNA-seq expression plotting script not found: {script}")
|
||
resolved_matrices = resolve_rnaseq_expression_matrices(
|
||
final_dir=final_dir,
|
||
prefix=prefix,
|
||
fpkm_file=fpkm_file,
|
||
tpm_file=tpm_file,
|
||
vst_file=vst_file,
|
||
)
|
||
fpkm_file = Path(resolved_matrices["fpkm_file"])
|
||
tpm_file = Path(resolved_matrices["tpm_file"])
|
||
vst_file = Path(resolved_matrices["vst_file"])
|
||
tpm_source = str(resolved_matrices.get("tpm_source", "unknown"))
|
||
tpm_source_reason = str(resolved_matrices.get("tpm_source_reason", ""))
|
||
matrix_bridge_qc_file = resolved_matrices.get("matrix_bridge_qc_file")
|
||
required_paths = {
|
||
"fpkm_file": fpkm_file,
|
||
"tpm_file": tpm_file,
|
||
"vst_file": vst_file,
|
||
"sample_table": sample_table,
|
||
"de_dir": de_dir,
|
||
}
|
||
missing = [name for name, path in required_paths.items() if not path.exists()]
|
||
if missing:
|
||
raise FileNotFoundError("Missing RNA-seq expression inputs: " + ", ".join(missing))
|
||
|
||
cmd = [
|
||
rnaseq_rscript(),
|
||
str(script),
|
||
f"fpkm={fpkm_file}",
|
||
f"tpm={tpm_file}",
|
||
f"vst={vst_file}",
|
||
f"sample_table={sample_table}",
|
||
f"de_dir={de_dir}",
|
||
f"prefix={prefix}",
|
||
f"out_dir={final_dir}",
|
||
f"top_n={top_n}",
|
||
f"time_top_n={time_top_n}",
|
||
f"tpm_source={tpm_source}",
|
||
]
|
||
if matrix_bridge_qc_file:
|
||
cmd.append(f"matrix_bridge_qc={matrix_bridge_qc_file}")
|
||
if count_matrix and count_matrix.exists():
|
||
cmd.append(f"count_matrix={count_matrix}")
|
||
if family_members and family_members.exists():
|
||
cmd.append(f"gene_family_members={family_members}")
|
||
cmd.append(f"family_label={family_label or family_members.stem}")
|
||
|
||
start_time = datetime.now()
|
||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT), capture_output=True, text=True, timeout=1800)
|
||
end_time = datetime.now()
|
||
status = "success" if result.returncode == 0 else "failed"
|
||
|
||
artifacts: Dict[str, Dict[str, Any]] = {}
|
||
for file in sorted(final_dir.glob(f"{prefix}.rnaseq_expression_plot.*")):
|
||
if not file.is_file():
|
||
continue
|
||
suffix = file.suffix.lower().lstrip(".")
|
||
if suffix in {"png"}:
|
||
artifact_type = "image"
|
||
elif suffix in {"pdf"}:
|
||
artifact_type = "plot"
|
||
elif suffix in {"tsv", "txt"}:
|
||
artifact_type = "table"
|
||
else:
|
||
artifact_type = "file"
|
||
key = file.name.replace(f"{prefix}.", "").replace(".", "_")
|
||
artifacts[key] = {
|
||
"artifact": key,
|
||
"path": f"final/{file.name}",
|
||
"full_path": abs_text(file),
|
||
"type": artifact_type,
|
||
"format": suffix,
|
||
"size": file.stat().st_size,
|
||
"sha256": compute_file_hash(file),
|
||
}
|
||
de_tpm_consistency = final_dir / f"{prefix}.rnaseq_expression_plot.de_tpm_consistency.tsv"
|
||
missing_de_genes_in_tpm: Optional[int] = None
|
||
if de_tpm_consistency.exists():
|
||
try:
|
||
with open(de_tpm_consistency, "r", encoding="utf-8", errors="ignore") as handle:
|
||
header = handle.readline().rstrip("\n").split("\t")
|
||
missing_idx = header.index("missing_de_genes_in_tpm") if "missing_de_genes_in_tpm" in header else -1
|
||
if missing_idx >= 0:
|
||
total_missing = 0
|
||
for line in handle:
|
||
parts = line.rstrip("\n").split("\t")
|
||
if missing_idx < len(parts) and parts[missing_idx]:
|
||
total_missing += int(float(parts[missing_idx]))
|
||
missing_de_genes_in_tpm = total_missing
|
||
except Exception:
|
||
missing_de_genes_in_tpm = None
|
||
|
||
manifest_path = final_dir / f"{prefix}.expression.manifest.json"
|
||
report_path = final_dir / f"{prefix}.expression_report.md"
|
||
if manifest_path.exists():
|
||
try:
|
||
existing_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
existing_manifest = {}
|
||
existing_params = existing_manifest.get("parameters") if isinstance(existing_manifest.get("parameters"), dict) else {}
|
||
existing_is_gene_family = bool(
|
||
existing_manifest.get("scope_id")
|
||
or existing_params.get("scope_id")
|
||
or (existing_manifest.get("stage_context") or {}).get("scope_id")
|
||
) and not bool(existing_manifest.get("rnaseq_scope_id") or existing_params.get("rnaseq_scope_id"))
|
||
if existing_is_gene_family:
|
||
manifest_path = final_dir / f"{prefix}.rnaseq_expression.manifest.json"
|
||
report_path = final_dir / f"{prefix}.rnaseq_expression_report.md"
|
||
report_path.write_text(
|
||
"\n".join([
|
||
f"# RNA-seq Expression 自动绘图报告 - {prefix}",
|
||
"",
|
||
f"- 状态: {status}",
|
||
f"- 触发阶段: {triggered_by}",
|
||
f"- FPKM: {fpkm_file}",
|
||
f"- TPM: {tpm_file}",
|
||
f"- TPM source: {tpm_source}",
|
||
f"- TPM source reason: {tpm_source_reason}",
|
||
f"- Matrix bridge QC: {matrix_bridge_qc_file if matrix_bridge_qc_file else 'not found'}",
|
||
f"- Missing significant DE genes in TPM: {missing_de_genes_in_tpm if missing_de_genes_in_tpm is not None else 'not available'}",
|
||
f"- VST: {vst_file}",
|
||
f"- DE目录: {de_dir}",
|
||
f"- Family members: {family_members if family_members else 'not provided'}",
|
||
f"- Artifacts: {len(artifacts)}",
|
||
"",
|
||
"```bash",
|
||
" ".join(str(x) for x in cmd),
|
||
"```",
|
||
"",
|
||
"## stderr tail",
|
||
"```",
|
||
(result.stderr or "")[-2000:],
|
||
"```",
|
||
]) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
artifacts["report"] = {
|
||
"artifact": "report",
|
||
"path": f"final/{report_path.name}",
|
||
"full_path": abs_text(report_path),
|
||
"type": "markdown",
|
||
"format": "md",
|
||
"size": report_path.stat().st_size,
|
||
"sha256": compute_file_hash(report_path),
|
||
}
|
||
|
||
rnaseq_context = build_rnaseq_context(
|
||
prefix=prefix,
|
||
count_matrix=count_matrix,
|
||
sample_table=sample_table,
|
||
foreground_mode="family" if family_members and family_members.exists() else "global",
|
||
family_members_file=family_members if family_members and family_members.exists() else None,
|
||
family_label=family_label,
|
||
stage="expression",
|
||
)
|
||
manifest = {
|
||
"manifest_version": "1.0",
|
||
"job_id": job_id,
|
||
"stage": "expression",
|
||
"prefix": prefix,
|
||
"run_id": run_id,
|
||
"timestamp": end_time.isoformat(),
|
||
"start_time": start_time.isoformat(),
|
||
"end_time": end_time.isoformat(),
|
||
"elapsed_sec": round((end_time - start_time).total_seconds(), 2),
|
||
"status": status,
|
||
"triggered_by": triggered_by,
|
||
"mode": "rnaseq_auto",
|
||
"cmd": [" ".join(str(x) for x in cmd)],
|
||
"workspace_root": abs_text(workspace.parent),
|
||
"workspace": abs_text(workspace),
|
||
"input_dir": abs_text(workspace.parent.parent / "input" / job_id),
|
||
"parameters": {
|
||
"mode": "rnaseq_auto",
|
||
"triggered_by": triggered_by,
|
||
"fpkm_file": abs_text(fpkm_file),
|
||
"tpm_file": abs_text(tpm_file),
|
||
"tpm_source": tpm_source,
|
||
"tpm_source_reason": tpm_source_reason,
|
||
"matrix_bridge_qc": abs_text(matrix_bridge_qc_file) if matrix_bridge_qc_file else "",
|
||
"display_evidence_policy": resolved_matrices.get("display_evidence_policy", ""),
|
||
"vst_file": abs_text(vst_file),
|
||
"sample_table": abs_text(sample_table),
|
||
"de_dir": abs_text(de_dir),
|
||
"count_matrix": abs_text(count_matrix) if count_matrix else "",
|
||
"family_members": abs_text(family_members) if family_members else "",
|
||
"family_label": family_label,
|
||
"top_n": top_n,
|
||
"time_top_n": time_top_n,
|
||
},
|
||
"metrics": {
|
||
"total_artifacts": len(artifacts),
|
||
"returncode": result.returncode,
|
||
"family_branch": bool(family_members and family_members.exists()),
|
||
"matrix_bridge_qc": resolved_matrices.get("matrix_bridge_qc", {}),
|
||
"missing_de_genes_in_tpm": missing_de_genes_in_tpm,
|
||
"display_evidence_policy": resolved_matrices.get("display_evidence_policy", ""),
|
||
},
|
||
"artifacts": artifacts,
|
||
"provenance": {
|
||
"derived_from": [
|
||
f"{prefix}.rnaseq_quant.manifest.json",
|
||
f"{prefix}.rnaseq_sample_qc.manifest.json",
|
||
f"{prefix}.rnaseq_de.manifest.json",
|
||
],
|
||
"stage_sequence": ["rnaseq_quant", "rnaseq_sample_qc", "rnaseq_de", "expression"],
|
||
},
|
||
"stdout_tail": (result.stdout or "")[-2000:],
|
||
"stderr_tail": (result.stderr or "")[-2000:],
|
||
}
|
||
apply_rnaseq_context(manifest, rnaseq_context)
|
||
artifacts["manifest"] = {
|
||
"artifact": "manifest",
|
||
"path": f"final/{manifest_path.name}",
|
||
"full_path": abs_text(manifest_path),
|
||
"type": "json",
|
||
"format": "json",
|
||
}
|
||
manifest["artifacts"] = artifacts
|
||
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"RNA-seq expression plotting failed; see {manifest_path}")
|
||
return manifest_path
|
||
|
||
|
||
def run_expression_analysis(
|
||
expression_file: Path,
|
||
gene_id_file: Path,
|
||
sample_file: Path,
|
||
output_prefix: str,
|
||
output_dir: Path,
|
||
id_transform: str = "none"
|
||
) -> Dict[str, Any]:
|
||
"""运行表达量分析并生成PDF和PNG"""
|
||
|
||
# 处理基因ID转换
|
||
processed_gene_file = output_dir / f"{output_prefix}.geneID.processed"
|
||
|
||
if id_transform == "remove_T01":
|
||
# 去除.T01后缀
|
||
with open(gene_id_file, 'r') as f:
|
||
lines = f.readlines()
|
||
with open(processed_gene_file, 'w') as f:
|
||
for line in lines:
|
||
parts = line.strip().split('\t')
|
||
if parts:
|
||
gene_id = parts[0].replace('.T01', '')
|
||
f.write(gene_id + '\t' + '\t'.join(parts[1:]) + '\n')
|
||
print(f" 已转换基因ID (去除.T01后缀): {processed_gene_file}")
|
||
elif id_transform == "add_T01":
|
||
# 添加.T01后缀
|
||
with open(gene_id_file, 'r') as f:
|
||
lines = f.readlines()
|
||
with open(processed_gene_file, 'w') as f:
|
||
for line in lines:
|
||
parts = line.strip().split('\t')
|
||
if parts:
|
||
gene_id = parts[0] + '.T01'
|
||
f.write(gene_id + '\t' + '\t'.join(parts[1:]) + '\n')
|
||
print(f" 已转换基因ID (添加.T01后缀): {processed_gene_file}")
|
||
else:
|
||
# 不转换
|
||
processed_gene_file = gene_id_file
|
||
|
||
# 构建命令 - 使用完整路径避免conda run的全局锁
|
||
rscript_path = rnar_rscript()
|
||
cmd = [
|
||
rscript_path, str(Path("scripts/draw_expression.R")),
|
||
str(expression_file),
|
||
str(processed_gene_file),
|
||
str(sample_file),
|
||
output_prefix
|
||
]
|
||
|
||
print(f" 命令: {' '.join(cmd)}")
|
||
|
||
# 执行
|
||
start_time = time.time()
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=str(Path(".")), # 从项目根目录运行
|
||
timeout=600
|
||
)
|
||
|
||
elapsed = time.time() - start_time
|
||
|
||
# 收集输出文件
|
||
output_files = []
|
||
for ext in ['.pdf', '.txt']:
|
||
# 也搜索项目根目录(R脚本可能输出到那里)
|
||
files = list(output_dir.glob(f"{output_prefix}*{ext}"))
|
||
root_files = list(Path(".").glob(f"{output_prefix}*{ext}"))
|
||
for f in root_files:
|
||
if str(f) not in files:
|
||
files.append(f)
|
||
output_files.extend([str(f) for f in files])
|
||
|
||
# 转换PDF为PNG
|
||
print(f" 转换PDF为PNG...")
|
||
# 收集所有PDF文件(从output_dir和根目录)
|
||
pdf_files = list(output_dir.glob(f"{output_prefix}*.pdf"))
|
||
root_pdf_files = list(Path(".").glob(f"{output_prefix}*.pdf"))
|
||
for f in root_pdf_files:
|
||
if str(f) not in [str(p) for p in pdf_files]:
|
||
pdf_files.append(f)
|
||
|
||
# 如果PDF在根目录,复制到output_dir
|
||
for pdf_file in pdf_files:
|
||
if pdf_file.parent != output_dir:
|
||
import shutil
|
||
dest = output_dir / pdf_file.name
|
||
shutil.copy(pdf_file, dest)
|
||
print(f" 复制文件到output_dir: {pdf_file.name}")
|
||
pdf_files = [dest if str(f) == str(pdf_file) else f for f in pdf_files]
|
||
|
||
for pdf_file in pdf_files:
|
||
png_file = pdf_file.with_suffix('.png')
|
||
try:
|
||
convert_cmd = ["pdftoppm", "-png", "-singlefile", "-r", "150", str(pdf_file), str(png_file.with_suffix(''))]
|
||
conv_result = subprocess.run(convert_cmd, capture_output=True, text=True, timeout=60)
|
||
if conv_result.returncode == 0 and png_file.exists():
|
||
output_files.append(str(png_file))
|
||
print(f" 转换成功: {png_file.name}")
|
||
else:
|
||
print(f" 转换失败: {pdf_file.name}")
|
||
except Exception as e:
|
||
print(f" 转换异常: {e}")
|
||
|
||
print(f" 生成 {len(output_files)} 个输出文件")
|
||
for f in output_files[:10]:
|
||
print(f" - {Path(f).name}")
|
||
|
||
return {
|
||
"success": result.returncode == 0,
|
||
"command": " ".join(cmd),
|
||
"returncode": result.returncode,
|
||
"stdout": result.stdout[-500:] if result.stdout else "",
|
||
"stderr": result.stderr[-500:] if result.stderr else "",
|
||
"output_files": output_files,
|
||
"elapsed_sec": elapsed
|
||
}
|
||
|
||
|
||
def write_report(
|
||
workspace: Path,
|
||
prefix: str,
|
||
metrics: Dict[str, Any],
|
||
parameters: Dict[str, Any],
|
||
tool_versions: Dict[str, str],
|
||
analysis_result: Dict[str, Any],
|
||
artifacts: Dict[str, Dict]
|
||
) -> Path:
|
||
"""写入人可读的分析报告"""
|
||
report_path = workspace / "final" / f"{prefix}.expression_report.md"
|
||
|
||
print(f"生成分析报告: {report_path}")
|
||
|
||
with open(report_path, "w") as f:
|
||
f.write(f"# Expression分析报告 - {prefix}\n\n")
|
||
f.write(f"**生成时间**: {datetime.now().isoformat()}\n\n")
|
||
|
||
f.write("## 1. 分析概述\n")
|
||
f.write(f"- **分析阶段**: Expression\n")
|
||
f.write(f"- **分析状态**: {'成功' if analysis_result.get('success', False) else '失败'}\n")
|
||
f.write(f"- **基因数量**: {metrics.get('gene_count', 'N/A')}\n")
|
||
f.write(f"- **输出文件数**: {metrics.get('total_output_files', 'N/A')}\n")
|
||
f.write(f"- **分析耗时**: {metrics.get('elapsed_sec', 0):.1f} 秒\n\n")
|
||
|
||
f.write("## 2. 工具版本\n")
|
||
for tool, version in tool_versions.items():
|
||
f.write(f"- **{tool}**: {version}\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 3. 执行参数\n")
|
||
for param, value in parameters.items():
|
||
f.write(f"- **{param}**: {value}\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 4. 执行命令\n")
|
||
f.write(f"```bash\n{analysis_result.get('command', 'N/A')}\n```\n\n")
|
||
|
||
if analysis_result.get('returncode', -1) != 0:
|
||
f.write(f"**返回码**: {analysis_result.get('returncode', 'N/A')}\n")
|
||
if analysis_result.get('stderr'):
|
||
f.write(f"**错误信息**:\n```\n{analysis_result['stderr'][:500]}\n```\n\n")
|
||
|
||
f.write("## 5. 产出文件\n")
|
||
for artifact_name, artifact_info in artifacts.items():
|
||
if isinstance(artifact_info, dict):
|
||
f.write(f"- **{artifact_name}**: {artifact_info.get('path', 'N/A')}\n")
|
||
if artifact_info.get('size'):
|
||
f.write(f" - 大小: {artifact_info['size']} 字节\n")
|
||
if artifact_info.get('sha256'):
|
||
f.write(f" - SHA256: {artifact_info['sha256'][:16]}...\n")
|
||
f.write("\n")
|
||
|
||
f.write("## 6. 详细统计\n")
|
||
f.write("```json\n")
|
||
f.write(json.dumps(metrics, indent=2, ensure_ascii=False))
|
||
f.write("\n```\n")
|
||
|
||
return report_path
|
||
|
||
|
||
def write_manifest(
|
||
workspace: Path,
|
||
job_id: str,
|
||
run_id: str,
|
||
prefix: str,
|
||
start_time: datetime,
|
||
end_time: datetime,
|
||
artifacts: Dict[str, Dict],
|
||
metrics: Dict[str, Any],
|
||
parameters: Dict[str, Any],
|
||
tool_versions: Dict[str, str],
|
||
cmd_history: List[str],
|
||
identify_inputs: List[Dict],
|
||
analysis_result: Dict[str, Any] = None,
|
||
status: str = "success",
|
||
stage_context: Any = None,
|
||
) -> Path:
|
||
"""
|
||
写入符合schema v1的manifest文件
|
||
|
||
注意:artifacts必须是字典格式,不是列表
|
||
新增:method_summary, comparative_summary, biological_notes(模板标准)
|
||
"""
|
||
elapsed_sec = (end_time - start_time).total_seconds()
|
||
|
||
# === 构建method_summary ===
|
||
method_summary = {
|
||
"data_type": "FPKM/TPM expression matrix",
|
||
"visualization": {
|
||
"heatmap": True, # R脚本会生成
|
||
"correlation": True,
|
||
"grouped_barplot": True,
|
||
"tissue_specificity": True # 新增的组织特异性分析
|
||
},
|
||
"statistics": {
|
||
"correlation_method": "Pearson",
|
||
"tissue_specificity_index": "Tau index",
|
||
"clustering": "hierarchical"
|
||
},
|
||
"tool": "R (pheatmap/ggplot2/tidyverse)"
|
||
}
|
||
|
||
# === 构建comparative_summary ===
|
||
gene_count = metrics.get("gene_count", 0)
|
||
|
||
# 尝试读取tissue_specificity.txt获取组织特异性统计
|
||
tissue_spec_summary = {}
|
||
final_dir = workspace / "final"
|
||
tissue_spec_file = final_dir / f"{prefix}.tissue_specificity.txt"
|
||
|
||
if tissue_spec_file.exists():
|
||
try:
|
||
import csv
|
||
specificity_categories = {}
|
||
max_groups = {}
|
||
with open(tissue_spec_file, 'r') as f:
|
||
reader = csv.DictReader(f, delimiter='\t')
|
||
for row in reader:
|
||
category = row.get('specificity_category', 'Unknown')
|
||
specificity_categories[category] = specificity_categories.get(category, 0) + 1
|
||
max_group = row.get('max_group', 'Unknown')
|
||
max_groups[max_group] = max_groups.get(max_group, 0) + 1
|
||
|
||
tissue_spec_summary = {
|
||
"specificity_distribution": specificity_categories,
|
||
"max_group_distribution": max_groups,
|
||
"specific_genes": specificity_categories.get('Specific', 0),
|
||
"broad_genes": specificity_categories.get('Broad', 0)
|
||
}
|
||
except Exception as e:
|
||
print(f"Warning: Could not parse tissue_specificity.txt: {e}")
|
||
|
||
# 提取物种信息
|
||
scoped_targets = parameters.get("target_species") if isinstance(parameters, dict) else None
|
||
if isinstance(scoped_targets, list) and len(scoped_targets) == 1:
|
||
target_species = str(scoped_targets[0])
|
||
else:
|
||
target_species = prefix.split('.')[0] if '.' in prefix else prefix.split('_')[0]
|
||
|
||
comparative_summary = {
|
||
"total_genes": gene_count,
|
||
"target_species": target_species,
|
||
"data_source": parameters.get("expression_file", "unknown"),
|
||
"groups": "multiple" if artifacts.get("gene_exp_hist_png") else "single",
|
||
"tissue_specificity": tissue_spec_summary,
|
||
"observation": f"Expression analysis for {gene_count} gene family members" if gene_count else "Expression analysis completed"
|
||
}
|
||
|
||
manifest_data = {
|
||
# 基础标识字段(必需)
|
||
"manifest_version": "1.0",
|
||
"job_id": job_id,
|
||
"stage": "expression",
|
||
"run_id": run_id,
|
||
"prefix": prefix,
|
||
|
||
# 状态和时间字段(必需)
|
||
"timestamp": end_time.isoformat(),
|
||
"status": status,
|
||
"start_time": start_time.isoformat(),
|
||
"end_time": end_time.isoformat(),
|
||
"elapsed_sec": round(elapsed_sec, 2),
|
||
|
||
# 执行信息(必需)
|
||
"cmd": cmd_history,
|
||
"tool_versions": tool_versions,
|
||
|
||
# 目录路径(必需)
|
||
"workspace_root": abs_text(workspace.parent),
|
||
"workspace": abs_text(workspace),
|
||
"input_dir": abs_text(workspace.parent.parent / "input" / job_id),
|
||
|
||
# 输入文件(必需)
|
||
"inputs": identify_inputs,
|
||
|
||
# 输出产物(必需)- 必须是字典格式
|
||
"artifacts": artifacts,
|
||
|
||
# 参数和指标(必需)
|
||
"parameters": parameters,
|
||
"metrics": metrics,
|
||
|
||
# 溯源信息(可选但推荐)
|
||
"provenance": {
|
||
"derived_from": f"{prefix}.identify.manifest.json",
|
||
"stage_sequence": ["identify", "expression"]
|
||
},
|
||
|
||
# ========== 新增:method_summary(模板标准)==========
|
||
"method_summary": method_summary,
|
||
|
||
# ========== 新增:comparative_summary(模板标准)==========
|
||
"comparative_summary": comparative_summary,
|
||
|
||
# ========== 新增:biological_notes(预留Agent填充)==========
|
||
"biological_notes": {
|
||
"result_parser": "", # Agent填充:生物学意义解读
|
||
"discovery": "" # Agent填充:异常/机会/故事线
|
||
}
|
||
}
|
||
|
||
for key in (
|
||
"scope_id",
|
||
"scope_mode",
|
||
"scope_file",
|
||
"target_species",
|
||
"reference_species",
|
||
"comparative_species",
|
||
"selected_species",
|
||
"family",
|
||
"analysis_route",
|
||
"member_set_id",
|
||
):
|
||
if key in parameters:
|
||
manifest_data[key] = parameters[key]
|
||
if stage_context is not None:
|
||
manifest_data = apply_stage_context(manifest_data, stage_context)
|
||
|
||
manifest_path = workspace / "final" / f"{prefix}.expression.manifest.json"
|
||
|
||
print(f"生成Manifest文件: {manifest_path}")
|
||
|
||
# 确保JSON严格性:无注释,确保ASCII
|
||
with open(manifest_path, "w", encoding='utf-8') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
|
||
if parameters.get("scope_id"):
|
||
scoped_manifest = workspace / "final" / f"{prefix}.{sanitize_token(str(parameters['scope_id']))}.expression.manifest.json"
|
||
manifest_data["scope_manifest"] = abs_text(scoped_manifest)
|
||
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="Expression阶段:基因表达量分析与可视化",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
python run_expression.py --prefix Ft.SPL --workspace /path/to/workspace \\
|
||
--expression-file expression_matrix.txt --gene-id-file gene_ids.txt \\
|
||
--sample-file sample_info.txt
|
||
|
||
输入要求:
|
||
- Identify阶段必须已成功运行
|
||
- final/{prefix}.final_members.id 必须存在
|
||
|
||
输出产物:
|
||
- final/{prefix}.expression.manifest.json (机器清单)
|
||
- final/{prefix}.expression_report.md (分析报告)
|
||
- final/{prefix}*.pdf (表达量图 PDF)
|
||
- final/{prefix}*.png (表达量图 PNG)
|
||
"""
|
||
)
|
||
|
||
parser.add_argument("--prefix", required=True,
|
||
help="分析前缀,格式:物种.家族(如 Ft.SPL)")
|
||
parser.add_argument("--workspace", required=True,
|
||
help="工作空间目录(workspace/<job_id>/)")
|
||
parser.add_argument("--mode", default="legacy", choices=["legacy", "rnaseq_auto"],
|
||
help="运行模式:legacy=用户提供表达矩阵+gene list;rnaseq_auto=RNA-seq DE后自动绘图")
|
||
parser.add_argument("--expression-file", required=True,
|
||
help="表达量矩阵文件路径;rnaseq_auto模式下为FPKM矩阵")
|
||
parser.add_argument("--gene-id-file", default="",
|
||
help="基因ID列表文件路径;rnaseq_auto模式下可选,用于family分支")
|
||
parser.add_argument("--sample-file", required=True,
|
||
help="样本信息文件路径;rnaseq_auto模式下为sample_table.tsv")
|
||
parser.add_argument("--tpm-file", default="", help="rnaseq_auto模式使用的TPM矩阵")
|
||
parser.add_argument("--vst-file", default="", help="rnaseq_auto模式使用的VST矩阵")
|
||
parser.add_argument("--count-matrix", default="", help="rnaseq_auto模式可选count矩阵,用于normalized count/time-course图")
|
||
parser.add_argument("--de-dir", default="", help="rnaseq_auto模式使用的DE结果目录")
|
||
parser.add_argument("--family-label", default="", help="rnaseq_auto family分支标签")
|
||
parser.add_argument("--triggered-by", default="manual", help="自动触发来源阶段")
|
||
parser.add_argument("--id-transform", default="none",
|
||
choices=["none", "remove_T01", "add_T01"],
|
||
help="ID转换方式(默认:none)")
|
||
parser.add_argument("--scope-file",
|
||
help="target/reference/comparative scope JSON;Expression只允许单个target物种")
|
||
parser.add_argument("--analysis-route", default="",
|
||
help="gene-family stage analysis route, e.g. single_species")
|
||
parser.add_argument("--member-set-id", default="",
|
||
help="gene-family member set identity for scoped status validation")
|
||
parser.add_argument("--log-file",
|
||
help="日志文件路径(默认:workspace/logs/{timestamp}_expression.log)")
|
||
|
||
args = parser.parse_args()
|
||
|
||
# 记录开始时间
|
||
start_time = datetime.now()
|
||
run_id = f"expression_{start_time.strftime('%Y%m%d_%H%M%S_%f')[:-3]}"
|
||
|
||
# 设置工作空间
|
||
workspace = Path(args.workspace)
|
||
job_id = workspace.name # workspace/<job_id>中的job_id
|
||
|
||
print("=" * 60)
|
||
print(f"🚀 启动Expression分析")
|
||
print("=" * 60)
|
||
print(f"分析前缀: {args.prefix}")
|
||
print(f"工作空间: {workspace}")
|
||
print(f"任务ID: {job_id}")
|
||
print(f"运行ID: {run_id}")
|
||
|
||
if args.mode == "rnaseq_auto":
|
||
try:
|
||
manifest_path = run_rnaseq_auto_expression(
|
||
workspace=workspace,
|
||
prefix=args.prefix,
|
||
job_id=job_id,
|
||
run_id=run_id,
|
||
fpkm_file=Path(args.expression_file),
|
||
tpm_file=Path(args.tpm_file),
|
||
vst_file=Path(args.vst_file),
|
||
sample_table=Path(args.sample_file),
|
||
de_dir=Path(args.de_dir),
|
||
count_matrix=Path(args.count_matrix) if args.count_matrix else None,
|
||
family_members=Path(args.gene_id_file) if args.gene_id_file else None,
|
||
family_label=args.family_label,
|
||
triggered_by=args.triggered_by,
|
||
)
|
||
print(f"✅ RNA-seq Expression自动绘图完成: {manifest_path}")
|
||
sys.exit(0)
|
||
except Exception as exc:
|
||
print(f"❌ RNA-seq Expression自动绘图失败: {exc}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
sys.exit(2)
|
||
|
||
stage_context = None
|
||
|
||
try:
|
||
scope_file = Path(args.scope_file) if args.scope_file else None
|
||
scope_data = None
|
||
analysis_route = str(args.analysis_route or "").strip()
|
||
member_set_id = str(args.member_set_id or "").strip()
|
||
if scope_file:
|
||
if not scope_file.exists():
|
||
raise FileNotFoundError(f"Scope file not found: {scope_file}")
|
||
scope_data = read_scope(scope_file)
|
||
target_species = scope_data.get("target_species", [])
|
||
if not analysis_route:
|
||
analysis_route = infer_analysis_route("expression", scope_data)
|
||
if not member_set_id and analysis_route and analysis_route != "unscoped" and scope_data.get("scope_id"):
|
||
member_set_id = member_set_id_for(args.prefix, scope_data, analysis_route)
|
||
stage_context = build_stage_context(
|
||
stage="expression",
|
||
prefix=args.prefix,
|
||
scope_data=scope_data,
|
||
scope_file=scope_file,
|
||
analysis_route=analysis_route,
|
||
member_set_id=member_set_id,
|
||
)
|
||
if len(target_species) != 1:
|
||
raise ValueError(
|
||
f"Expression 阶段一次只能分析一个目标物种;当前 scope target_species={target_species}。"
|
||
"请在弹窗中只选择一个 target,或分别运行每个目标物种。"
|
||
)
|
||
|
||
if not args.gene_id_file:
|
||
raise ValueError("legacy expression mode requires --gene-id-file")
|
||
|
||
# 1. 准备目录结构
|
||
print("\n📁 准备目录结构...")
|
||
paths = prepare_directories(workspace)
|
||
|
||
# 2. 获取工具版本
|
||
print("\n🔧 检查工具版本...")
|
||
tool_versions = get_tool_versions()
|
||
print(f" R: {tool_versions.get('R', 'unknown')}")
|
||
print(f" pdftoppm: {tool_versions.get('pdftoppm', 'unknown')}")
|
||
print(f" python: {tool_versions.get('python', 'unknown')}")
|
||
|
||
# 3. 验证Identify输出
|
||
print("\n🔍 验证Identify阶段输出...")
|
||
identify_info = validate_previous_stage(
|
||
workspace,
|
||
args.prefix,
|
||
gene_id_override=Path(args.gene_id_file) if scope_data else None,
|
||
)
|
||
identify_inputs = identify_info.get("identify_inputs", [])
|
||
gene_count = identify_info.get("gene_count", 0)
|
||
|
||
# 4. 运行表达量分析
|
||
print("\n🔄 运行表达量分析...")
|
||
result = run_expression_analysis(
|
||
expression_file=Path(args.expression_file),
|
||
gene_id_file=Path(args.gene_id_file),
|
||
sample_file=Path(args.sample_file),
|
||
output_prefix=f"{args.prefix}.expression",
|
||
output_dir=paths["final_dir"],
|
||
id_transform=args.id_transform
|
||
)
|
||
|
||
# 5. 确定分析状态
|
||
status = "success" if result.get("success", False) else "failed"
|
||
|
||
# 6. 准备artifacts信息(字典格式)
|
||
artifacts = {}
|
||
for f_path in result.get("output_files", []):
|
||
f = Path(f_path)
|
||
if f.exists():
|
||
artifact_name = f.stem.replace(f"{args.prefix}.expression.", "").replace(".", "_")
|
||
|
||
# 确定artifact类型
|
||
if f.suffix == ".pdf":
|
||
artifact_type = "plot"
|
||
format_type = "pdf"
|
||
elif f.suffix == ".png":
|
||
artifact_type = "image"
|
||
format_type = "png"
|
||
elif f.suffix == ".txt":
|
||
artifact_type = "table"
|
||
format_type = "txt"
|
||
else:
|
||
artifact_type = "file"
|
||
format_type = f.suffix.lstrip('.')
|
||
|
||
artifacts[artifact_name] = {
|
||
"artifact": artifact_name,
|
||
"path": f"final/{f.name}",
|
||
"full_path": str(f.absolute()),
|
||
"type": artifact_type,
|
||
"format": format_type,
|
||
"size": f.stat().st_size,
|
||
"sha256": compute_file_hash(f)
|
||
}
|
||
|
||
# 7. 准备metrics
|
||
metrics = {
|
||
"gene_count": gene_count,
|
||
"total_output_files": len(result.get("output_files", [])),
|
||
"total_artifacts": len(artifacts),
|
||
"elapsed_sec": result.get("elapsed_sec", 0)
|
||
}
|
||
|
||
# 8. 准备parameters
|
||
parameters = {
|
||
"prefix": args.prefix,
|
||
"id_transform": args.id_transform,
|
||
"expression_file": str(args.expression_file),
|
||
"gene_id_file": str(args.gene_id_file),
|
||
"sample_file": str(args.sample_file),
|
||
"stage": "expression",
|
||
"job_id": job_id,
|
||
"run_id": run_id,
|
||
"manifest_version": "1.0",
|
||
"status": status
|
||
}
|
||
parameters.update(scope_parameters(scope_file, scope_data, analysis_route, member_set_id))
|
||
|
||
# 9. 记录命令历史
|
||
cmd_history = [result.get("command", "")]
|
||
|
||
# 10. 写入报告
|
||
print("\n📝 生成分析报告...")
|
||
report_path = write_report(
|
||
workspace=workspace,
|
||
prefix=args.prefix,
|
||
metrics=metrics,
|
||
parameters=parameters,
|
||
tool_versions=tool_versions,
|
||
analysis_result=result,
|
||
artifacts=artifacts
|
||
)
|
||
|
||
# 添加报告到artifacts
|
||
if report_path.exists():
|
||
artifacts["report"] = {
|
||
"artifact": "report",
|
||
"path": f"final/{args.prefix}.expression_report.md",
|
||
"full_path": str(report_path.absolute()),
|
||
"type": "markdown",
|
||
"format": "md",
|
||
"size": report_path.stat().st_size,
|
||
"sha256": compute_file_hash(report_path)
|
||
}
|
||
|
||
# 11. 写入manifest
|
||
print("\n📋 生成Manifest文件...")
|
||
end_time = datetime.now()
|
||
manifest_path = write_manifest(
|
||
workspace=workspace,
|
||
job_id=job_id,
|
||
run_id=run_id,
|
||
prefix=args.prefix,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
artifacts=artifacts,
|
||
metrics=metrics,
|
||
parameters=parameters,
|
||
tool_versions=tool_versions,
|
||
cmd_history=cmd_history,
|
||
identify_inputs=identify_inputs,
|
||
analysis_result=result, # 新增:传递分析结果
|
||
status=status,
|
||
stage_context=stage_context,
|
||
)
|
||
|
||
# 添加manifest自身到artifacts
|
||
if manifest_path.exists():
|
||
artifacts["manifest"] = {
|
||
"artifact": "manifest",
|
||
"path": f"final/{args.prefix}.expression.manifest.json",
|
||
"full_path": str(manifest_path.absolute()),
|
||
"type": "json",
|
||
"format": "json",
|
||
"size": manifest_path.stat().st_size,
|
||
"sha256": compute_file_hash(manifest_path)
|
||
}
|
||
|
||
# 12. 更新manifest包含完整的artifacts
|
||
if manifest_path.exists():
|
||
with open(manifest_path, 'r') as f:
|
||
manifest_data = json.load(f)
|
||
manifest_data["artifacts"] = artifacts
|
||
with open(manifest_path, 'w') as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
if parameters.get("scope_id"):
|
||
scoped_manifest = workspace / "final" / f"{args.prefix}.{sanitize_token(str(parameters['scope_id']))}.expression.manifest.json"
|
||
manifest_data["scope_manifest"] = abs_text(scoped_manifest)
|
||
with open(scoped_manifest, "w", encoding="utf-8") as f:
|
||
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
|
||
|
||
# 13. 输出成功信息
|
||
elapsed = (end_time - start_time).total_seconds()
|
||
print("\n" + "=" * 60)
|
||
if status == "success":
|
||
print(f"✅ Expression分析完成!")
|
||
else:
|
||
print(f"❌ Expression分析失败!")
|
||
print(f" 总耗时: {elapsed:.1f} 秒")
|
||
print(f" 状态: {status}")
|
||
print(f" Manifest: {manifest_path}")
|
||
print(f" 报告: {report_path}")
|
||
print(f" 输出文件数: {len(result.get('output_files', []))}")
|
||
print("=" * 60)
|
||
|
||
# 根据状态返回退出码
|
||
if status == "success":
|
||
sys.exit(0)
|
||
else:
|
||
sys.exit(1)
|
||
|
||
except Exception as e:
|
||
# 记录结束时间
|
||
end_time = datetime.now()
|
||
|
||
print(f"\n❌ Expression分析失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
try:
|
||
# 创建基本的artifacts(可能部分成功)
|
||
artifacts = {}
|
||
|
||
# 尝试写入失败manifest
|
||
manifest_path = write_manifest(
|
||
workspace=workspace,
|
||
job_id=job_id,
|
||
run_id=run_id,
|
||
prefix=args.prefix,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
artifacts=artifacts,
|
||
metrics={},
|
||
parameters={
|
||
"prefix": args.prefix,
|
||
"error": str(e)[:200]
|
||
},
|
||
tool_versions=tool_versions if 'tool_versions' in locals() else {},
|
||
cmd_history=[],
|
||
identify_inputs=identify_inputs if 'identify_inputs' in locals() else [],
|
||
analysis_result=None, # 新增:失败时为None
|
||
status="failed",
|
||
stage_context=stage_context,
|
||
)
|
||
|
||
print(f" 失败Manifest已写入: {manifest_path}")
|
||
|
||
except Exception as manifest_error:
|
||
print(f" 写入失败Manifest时出错: {manifest_error}")
|
||
|
||
sys.exit(2)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|