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

1330 lines
52 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Structure stage: 基因结构可视化
符合 workspace_contract_v1.md 和 manifest_v1.schema.json 契约
"""
import os
import argparse
import json
import subprocess
import sys
import re
import uuid
import gzip
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import hashlib
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from gfa_core.gene_family_scope import read_scope, sanitize_token
from gfa_core.gene_family_stage_context import apply_stage_context, build_stage_context, user_output_prefix_for, write_legacy_manifest_copy
from gfa_core.mas.effective_members import MemberResolutionError, resolve_effective_members
CONDA_BIN = "/home/xzq/miniconda3/envs/test4py311/bin"
if CONDA_BIN not in os.environ.get("PATH", ""):
os.environ["PATH"] = CONDA_BIN + ":" + os.environ.get("PATH", "")
def display_path(path: Path | str) -> str:
"""Return the user-facing symlink path instead of resolving data-root links."""
return str(Path(path).absolute())
def current_input_dir(workspace: Path) -> Path:
return workspace.parent.parent / "input" / workspace.name
def inside_current_job(workspace: Path, path: Path) -> bool:
input_dir = current_input_dir(workspace)
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(workspace: Path, path: Path) -> Optional[Path]:
"""Map copied legacy workspace/input paths back onto the current job roots."""
parts = path.parts
for root_name, current_root in (("workspace", workspace), ("input", current_input_dir(workspace))):
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
def resolve_job_path(workspace: Path, raw_path: str | Path, manifest_path: Optional[Path] = None) -> Optional[Path]:
"""Resolve a copied job path without trusting stale absolute paths."""
raw_text = str(raw_path or "").strip()
if not raw_text:
return None
path = Path(raw_text)
if path.is_absolute():
if inside_current_job(workspace, path):
return path
rebased = rebase_current_job_path(workspace, path)
if rebased:
return rebased
return None
candidates: List[Path] = [workspace / path, current_input_dir(workspace) / path]
if manifest_path:
candidates.extend([manifest_path.parent / path, manifest_path.parent / path.name])
if str(path).startswith("final/") and manifest_path:
candidates.append(manifest_path.parent / path.name)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def manifest_artifact_path(workspace: Path, manifest_path: Path, artifact: Dict) -> Optional[Path]:
if not isinstance(artifact, dict):
return None
for key in ("path", "relpath_or_hint", "file", "full_path"):
resolved = resolve_job_path(workspace, artifact.get(key) or "", manifest_path)
if resolved:
return resolved
return None
def get_tool_versions() -> Dict[str, str]:
"""获取工具版本信息"""
versions = {}
try:
r = subprocess.run(["R", "--version"], capture_output=True, text=True, check=False)
if r.returncode == 0:
versions["R"] = r.stdout.split("\n")[0]
except:
versions["R"] = "not_found"
try:
import Bio
versions["biopython"] = Bio.__version__
except:
versions["biopython"] = "not_found"
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:
return ""
def read_gene_ids(id_file: Path) -> List[str]:
"""读取基因 ID 列表"""
ids = []
with open(id_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
ids.append(line)
return ids
def open_text_auto(path: Path):
if path.suffix == ".gz":
return gzip.open(path, "rt", encoding="utf-8", errors="ignore")
return open(path, "r", encoding="utf-8", errors="ignore")
def read_tree_order(tree_file: Path) -> List[str]:
"""从树文件读取叶节点顺序"""
try:
from Bio import Phylo
tree = Phylo.read(tree_file, 'newick')
tips = [clade.name for clade in tree.get_terminals()]
return tips if tips else []
except:
return []
def should_strip_version(raw_id: str) -> bool:
lowered = (raw_id or "").lower()
return not (lowered.startswith("evm.model.") or lowered.startswith("evm.tu."))
def strip_gff_id(raw_id: Optional[str]) -> str:
if not raw_id:
return ""
value = str(raw_id).strip()
# Tree/Newick labels may replace ':' with '_' (mRNA:HanXR -> mRNA_HanXR).
# Strip repeated typed wrappers so GFF attributes, FASTA IDs and tree-safe
# labels all resolve to the same member accession.
while True:
updated = re.sub(r"^(gene|transcript|CDS|mRNA)[:_]", "", value, flags=re.IGNORECASE)
if updated == value:
break
value = updated
value = re.sub(r'_[PT]\d+$', '', value)
if should_strip_version(value):
value = re.sub(r'\.\d+$', '', value)
return value
def id_variants(raw_id: Optional[str]) -> set[str]:
value = strip_gff_id(raw_id)
if not value:
return set()
variants = {value}
if should_strip_version(value) and re.search(r'\.\d+$', value):
variants.add(re.sub(r'\.\d+$', '', value))
variants.add(re.sub(r'_[PT]\d+$', '', value))
expanded = set()
for item in variants:
if item:
expanded.update({item, item.upper(), item.lower()})
expanded.update({f"gene:{item}", f"transcript:{item}", f"CDS:{item}", f"mRNA:{item}"})
return expanded
def build_target_id_lookup(gene_ids: List[str]) -> Dict[str, str]:
lookup: Dict[str, str] = {}
for gid in gene_ids:
canonical = str(gid).strip()
if not canonical:
continue
for variant in id_variants(canonical):
lookup.setdefault(variant, canonical)
return lookup
def canonical_target_id(raw_id: Optional[str], target_lookup: Dict[str, str]) -> Optional[str]:
for variant in id_variants(raw_id):
if variant in target_lookup:
return target_lookup[variant]
return None
def parse_gff3_attributes(attr: str) -> Dict[str, str]:
attr_map: Dict[str, str] = {}
for item in str(attr or "").split(";"):
if "=" not in item:
continue
key, value = item.split("=", 1)
attr_map[key] = value
return attr_map
def canonical_from_attr_values(attr_map: Dict[str, str], keys: List[str], target_lookup: Dict[str, str]) -> Optional[str]:
for key in keys:
value = attr_map.get(key)
if not value:
continue
for token in str(value).split(","):
canonical = canonical_target_id(token, target_lookup)
if canonical:
return canonical
return None
def resolve_gff3_path(ws: Path, prefix: str, cli_gff3: Optional[str] = None) -> Path:
"""
解析 GFF3 文件路径
命名规则: {species_short}_final.gff3 (如 Ft_final.gff3)
优先级: final/ > input/
Args:
ws: workspace 目录
prefix: 分析前缀 (如 Ft.SPL),从中提取物种简写 Ft
cli_gff3: 命令行指定的 GFF3 路径(可选)
Returns:
GFF3 文件路径
Raises:
FileNotFoundError: 找不到符合命名规范的 GFF3 文件
"""
# 方式一:命令行指定(如果提供)
if cli_gff3:
gff3_path = resolve_job_path(ws, cli_gff3) or Path(cli_gff3)
if gff3_path.exists():
print(f"[INFO] Using specified GFF3: {gff3_path}")
return gff3_path
else:
raise FileNotFoundError(f"Specified GFF3 not found: {cli_gff3}")
# 从 prefix 提取物种简写 (Ft.SPL -> Ft)
species_short = prefix.split('.')[0] if '.' in prefix else prefix
# 标准命名: Ft_final.gff3
gff3_name = f"{species_short}_final.gff3"
final_dir = ws / "final"
input_dir = ws / "input"
# 也检查 bbav2/input/{job_id}/ 路径
job_id = ws.name
alt_input_dir = ws.parent.parent / "input" / job_id
# 优先级: final/ > workspace/input/ > bbav2/input/{job_id}/
search_paths = []
if final_dir.exists():
search_paths.append(final_dir / gff3_name)
if input_dir.exists():
search_paths.append(input_dir / gff3_name)
if alt_input_dir.exists():
search_paths.append(alt_input_dir / gff3_name)
# 查找
for candidate in search_paths:
if candidate.exists():
print(f"[INFO] Found GFF3: {candidate}")
return candidate
# 都找不到,报错
searched = ", ".join([str(p) for p in search_paths]) if search_paths else "no directories available"
raise FileNotFoundError(
f"Cannot find GFF3 file '{gff3_name}' for prefix '{prefix}'. "
f"Searched in: {searched}. "
f"Please ensure the file follows naming convention: {{species_short}}_final.gff3 "
f"or specify --gff3 explicitly."
)
def find_prepared_gff3(ws: Path, species: str) -> Optional[Path]:
job_id = ws.name
candidates = [
ws / "final" / f"{species}_final.gff3",
ws / "input" / f"{species}_final.gff3",
ws.parent.parent / "input" / job_id / f"{species}_final.gff3",
ws.parent.parent / "input" / job_id / species / f"{species}_final.gff3",
]
for candidate in candidates:
if candidate.exists() and candidate.stat().st_size > 0:
return candidate.absolute()
return None
def resolve_scope_gff3_paths(scope_data: Dict, ws: Optional[Path] = None) -> List[Tuple[str, Path]]:
target_species = [str(item).strip() for item in scope_data.get("target_species", []) if str(item).strip()]
selected_species = [str(item).strip() for item in scope_data.get("selected_species", []) if str(item).strip()]
scope_mode = str(scope_data.get("scope_mode") or "")
species_for_structure = selected_species if scope_mode == "target_plus_reference" and selected_species else target_species
if not species_for_structure:
raise FileNotFoundError("Scope file does not define species for structure stage")
target_set = set(target_species)
resolved: List[Tuple[str, Path]] = []
missing: List[str] = []
files = scope_data.get("files", {})
for species in species_for_structure:
selected: Optional[Path] = None
if ws is not None and species in target_set:
selected = find_prepared_gff3(ws, species)
species_files = files.get(species, {})
if selected is None:
for record in species_files.get("gff", []):
path_text = str(record.get("path") or "")
path = resolve_job_path(ws, path_text) if (ws is not None and path_text) else (Path(path_text) if path_text else None)
if path and path.exists() and path.stat().st_size > 0:
selected = path
break
if selected:
resolved.append((species, selected.absolute()))
else:
missing.append(species)
if missing:
raise FileNotFoundError(
"Missing target GFF3 files in scope for structure stage: " + ", ".join(missing)
)
return resolved
def resolve_scope_tree_file(ws: Path, prefix: str, scope_data: Optional[Dict], cli_tree: Optional[str] = None) -> Path:
final_dir = ws / "final"
if cli_tree:
tree_path = resolve_job_path(ws, cli_tree) or Path(cli_tree)
if tree_path.exists() and tree_path.stat().st_size > 0:
return tree_path.absolute()
raise FileNotFoundError(f"Specified tree file not found: {cli_tree}")
manifest_candidates: List[Path] = []
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
if scope_id:
manifest_candidates.append(final_dir / f"{prefix}.{sanitize_token(scope_id)}.phylogeny.manifest.json")
else:
manifest_candidates.append(final_dir / f"{prefix}.phylogeny.manifest.json")
for manifest_file in manifest_candidates:
if not manifest_file.exists() or manifest_file.stat().st_size == 0:
continue
try:
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
except Exception:
continue
tree_path = None
artifact = (manifest.get("artifacts", {}) or {}).get("tree")
if isinstance(artifact, dict):
tree_path = manifest_artifact_path(ws, manifest_file, artifact)
elif isinstance(artifact, str):
tree_path = resolve_job_path(ws, artifact, manifest_file)
if tree_path and tree_path.exists() and tree_path.stat().st_size > 0:
return tree_path.absolute()
fallback = final_dir / f"{prefix}.tree.nwk"
if not scope_id and fallback.exists() and fallback.stat().st_size > 0:
return fallback.absolute()
raise FileNotFoundError(f"Tree file not found for prefix {prefix}. Please run phylogeny first or specify --tree.")
def filter_scope_gff3(
gff3_sources: List[Tuple[str, Path]],
gene_ids: List[str],
output_gff3: Path,
structure_dir: Path,
prefix: str,
) -> Tuple[bool, Dict]:
if len(gff3_sources) == 1:
return filter_gff3(gff3_sources[0][1], gene_ids, output_gff3)
aggregate = {
"total_lines": 0,
"filtered_lines": 0,
"genes_found": 0,
"mrna_count": 0,
"cds_count": 0,
"utr_count": 0,
"per_species": {},
}
temp_paths: List[Path] = []
for species, gff3_file in gff3_sources:
temp_path = structure_dir / f"{prefix}.{species}.structure.filtered.gff3"
success, stats = filter_gff3(gff3_file, gene_ids, temp_path)
if not success:
return False, aggregate
temp_paths.append(temp_path)
aggregate["per_species"][species] = stats
for key in ("total_lines", "filtered_lines", "genes_found", "mrna_count", "cds_count", "utr_count"):
aggregate[key] += int(stats.get(key, 0))
with output_gff3.open("w", encoding="utf-8") as out:
for temp_path in temp_paths:
with temp_path.open(encoding="utf-8", errors="ignore") as handle:
for raw in handle:
out.write(raw)
return True, aggregate
def resolve_subfamily_file(ws: Path, prefix: str, cli_subfamily: Optional[str] = None, output_prefix: Optional[str] = None, scope_data: Optional[Dict] = None) -> Optional[Path]:
"""
解析亚家族文件路径
命名规则: {prefix}.confirmed_subfamily.tsv (如 Ft.SPL.confirmed_subfamily.tsv)
优先级: final/ > input/
Args:
ws: workspace 目录
prefix: 分析前缀 (如 Ft.SPL)
cli_subfamily: 命令行指定的亚家族文件路径(可选)
Returns:
亚家族文件路径,或 None(可选)
"""
# 方式一:命令行指定
if cli_subfamily:
if str(cli_subfamily).strip().lower() in {"none", "skip", "disabled"}:
print("[INFO] Subfamily coloring explicitly disabled")
return None
subfamily_path = resolve_job_path(ws, cli_subfamily) or Path(cli_subfamily)
if subfamily_path.exists():
print(f"[INFO] Using specified subfamily file: {subfamily_path}")
return subfamily_path
else:
print(f"[WARNING] Specified subfamily file not found: {cli_subfamily}")
return None
# 自动查找只接受 confirmed_subfamily;prediction/geneID 必须由 --subfamily 显式传入。
names = []
bases = []
for base in [output_prefix, prefix]:
if base and base not in bases:
bases.append(base)
if output_prefix and ".subfamily_evidence" in output_prefix:
bases.append(output_prefix.replace(".subfamily_evidence", ".subfamily_prediction"))
scope_mode = str((scope_data or {}).get("scope_mode") or "").strip()
family = str((scope_data or {}).get("family") or (prefix.split(".")[-1] if "." in prefix else "")).strip()
targets = [str(x) for x in (scope_data or {}).get("target_species", []) if str(x).strip()]
refs = [str(x) for x in (scope_data or {}).get("reference_species", []) if str(x).strip()]
if scope_mode == "target_plus_reference" and len(targets) == 1 and family:
bases.append(f"{targets[0]}.{family}.target_plus_reference")
ref_part = "-".join(refs) if refs else ""
if ref_part:
bases.append(f"Target.{family}.target-{targets[0]}.ref-{ref_part}.subfamily_prediction")
seen_bases = set()
for base in bases:
if not base or base in seen_bases:
continue
seen_bases.add(base)
names.extend([
f"{base}.confirmed_subfamily.tsv",
])
subfamily_name = f"{prefix}.confirmed_subfamily.tsv"
final_dir = ws / "final"
input_dir = ws / "input"
# 也检查 bbav2/input/{job_id}/ 路径(用户上传文件的实际位置)
job_id = ws.name
alt_input_dir = ws.parent.parent / "input" / job_id
# 优先级: final/ > workspace/input/ > bbav2/input/{job_id}/
search_paths = []
if final_dir.exists():
search_paths.extend(final_dir / name for name in names)
if input_dir.exists():
search_paths.extend(input_dir / name for name in names)
if alt_input_dir.exists():
search_paths.extend(alt_input_dir / name for name in names)
for candidate in search_paths:
if candidate.exists():
print(f"[INFO] Found subfamily file: {candidate}")
return candidate
print(f"[INFO] No subfamily file found ({subfamily_name}), will use default coloring")
return None
def filter_gff3(gff3_file: Path, gene_ids: List[str], output_gff3: Path) -> Tuple[bool, Dict]:
"""
过滤 GFF3 文件,只保留目标基因。
ID matching is scope-aware: GFF3 gene/transcript/CDS prefixes, case, version
suffixes and _P/_T suffixes are normalized back to the requested member IDs.
NCBI/Gnomon records often use protein IDs as mRNA/CDS IDs while storing the
transcript accession in Name/transcript_id; those transcript aliases are
captured so CDS rows are assigned back to the requested XM_* member IDs.
"""
target_lookup = build_target_id_lookup(gene_ids)
transcript_to_gene: Dict[str, str] = {}
stats = {
"total_lines": 0,
"filtered_lines": 0,
"genes_found": set(),
"mrna_count": 0,
"cds_count": 0,
"utr_count": 0
}
with open_text_auto(gff3_file) as f_in, open(output_gff3, 'w') as f_out:
for line in f_in:
stats["total_lines"] += 1
if line.startswith('#'):
continue
line = re.sub(r'\t(\S+UTR)\t', '\tUTR\t', line, flags=re.IGNORECASE)
parts = line.strip().split('\t')
if len(parts) != 9:
continue
feat_type = parts[2]
if feat_type == "gene":
continue
attr_map = parse_gff3_attributes(parts[8])
feat_type_norm = feat_type.lower()
canonical_id = canonical_from_attr_values(
attr_map,
["ID", "Name", "transcript_id", "gene_id", "gene", "Parent"],
target_lookup,
)
canonical_parent = canonical_from_attr_values(
attr_map,
["Parent", "ID", "Name", "transcript_id", "gene_id", "gene"],
target_lookup,
)
parent_text = attr_map.get("Parent", "")
for parent_token in str(parent_text).split(","):
if parent_token in transcript_to_gene:
canonical_parent = transcript_to_gene[parent_token]
break
is_target = False
if feat_type_norm in {"mrna", "transcript"} and canonical_id:
is_target = True
stats["genes_found"].add(canonical_id)
stats["mrna_count"] += 1
for alias_key in ("ID", "Name", "transcript_id"):
alias = attr_map.get(alias_key)
if not alias:
continue
for token in str(alias).split(","):
if token:
transcript_to_gene[token] = canonical_id
elif feat_type_norm in {"cds", "utr", "exon"} and canonical_parent:
is_target = True
if feat_type_norm == "cds":
stats["cds_count"] += 1
elif feat_type_norm == "utr":
stats["utr_count"] += 1
if is_target:
f_out.write('\t'.join(parts) + '\n')
stats["filtered_lines"] += 1
stats["genes_found"] = len(stats["genes_found"])
return True, stats
def gff3_to_tsv(
gff3_file: Path,
output_tsv: Path,
tree_order: List[str],
target_gene_ids: Optional[List[str]] = None,
) -> Tuple[bool, Dict]:
"""将过滤后的 GFF3 转换为 TSV,并把 feature 归一到 scope/tree 成员 ID。"""
data = []
stats = {
"total_features": 0,
"genes_with_structure": set(),
"total_cds": 0,
"total_utr": 0,
"exon_counts": {},
"gene_lengths": {}
}
target_lookup = build_target_id_lookup(target_gene_ids or tree_order)
transcript_to_gene: Dict[str, str] = {}
seen_features: set[str] = set()
with open_text_auto(gff3_file) as f:
for line in f:
parts = line.strip().split('\t')
if len(parts) != 9:
continue
chrom, source, feat_type, start, end, score, strand, phase, attr = parts
feat_type_norm = feat_type.lower()
attr_map = parse_gff3_attributes(attr)
if feat_type_norm in {"mrna", "transcript"}:
gene_id = canonical_from_attr_values(
attr_map,
["ID", "Name", "transcript_id", "geneID", "gene_id", "gene", "Parent"],
target_lookup,
) if target_lookup else strip_gff_id(attr_map.get("ID") or attr_map.get("Name") or attr_map.get("transcript_id") or attr_map.get("Parent"))
if gene_id:
for alias_key in ("ID", "Name", "transcript_id"):
alias = attr_map.get(alias_key)
if not alias:
continue
for token in str(alias).split(","):
if token:
transcript_to_gene[token] = gene_id
else:
gene_id = canonical_from_attr_values(
attr_map,
["Parent", "ID", "Name", "transcript_id", "geneID", "gene_id", "gene"],
target_lookup,
) if target_lookup else strip_gff_id(attr_map.get("Parent") or attr_map.get("ID"))
parent_text = attr_map.get("Parent", "")
for parent_token in str(parent_text).split(","):
if parent_token in transcript_to_gene:
gene_id = transcript_to_gene[parent_token]
break
if not gene_id:
continue
start_num = int(start)
end_num = int(end)
feature_key = f"{gene_id}|{feat_type}|{start_num}|{end_num}|{strand}|{chrom}"
if feature_key in seen_features:
continue
seen_features.add(feature_key)
data.append({
'gene_id': gene_id,
'feature': feat_type,
'start': start_num,
'end': end_num,
'strand': strand,
'chrom': chrom
})
stats["total_features"] += 1
if feat_type == "CDS":
stats["genes_with_structure"].add(gene_id)
stats["total_cds"] += 1
stats["exon_counts"][gene_id] = stats["exon_counts"].get(gene_id, 0) + 1
elif feat_type == "UTR":
stats["genes_with_structure"].add(gene_id)
stats["total_utr"] += 1
if feat_type in {"CDS", "UTR"}:
if gene_id not in stats["gene_lengths"]:
stats["gene_lengths"][gene_id] = {"min": start_num, "max": end_num}
else:
stats["gene_lengths"][gene_id]["min"] = min(stats["gene_lengths"][gene_id]["min"], start_num)
stats["gene_lengths"][gene_id]["max"] = max(stats["gene_lengths"][gene_id]["max"], end_num)
if not data:
return False, stats
stats["genes_with_structure"] = len(stats["genes_with_structure"])
for gene_id, lengths in stats["gene_lengths"].items():
stats["gene_lengths"][gene_id] = lengths["max"] - lengths["min"] + 1
gene_list = sorted(set(d['gene_id'] for d in data))
if tree_order:
order_dict = {name: i for i, name in enumerate(tree_order)}
gene_list = sorted(gene_list, key=lambda x: order_dict.get(x, 999999))
gene_to_y = {g: i+1 for i, g in enumerate(gene_list)}
with open(output_tsv, 'w') as f:
f.write('gene_id\tfeature\tstart\tend\tstrand\tchrom\ty_pos\n')
for d in data:
y_pos = gene_to_y.get(d['gene_id'], 0)
f.write(f"{d['gene_id']}\t{d['feature']}\t{d['start']}\t{d['end']}\t{d['strand']}\t{d['chrom']}\t{y_pos}\n")
return True, stats
def calculate_structure_metrics(stats: Dict, gene_count: int) -> Dict:
"""计算结构相关的 metrics,并记录结构长度 QC。"""
genes_with_structure = min(int(stats.get("genes_with_structure", 0) or 0), max(gene_count, 0))
metrics = {
"genes_total": gene_count,
"genes_with_structure": genes_with_structure,
"structure_coverage_rate": round(genes_with_structure / max(gene_count, 1), 4),
"total_cds_features": stats.get("total_cds", 0),
"total_utr_features": stats.get("total_utr", 0),
"avg_exon_count_per_gene": 0.0,
"avg_gene_length_bp": 0.0,
"avg_cds_length_bp": 0.0,
"median_gene_length_bp": 0.0,
"max_gene_length_bp": 0,
"long_gene_outlier_threshold_bp": 0,
"long_gene_outlier_count": 0,
"top_longest_gene_structures": [],
"long_gene_outliers": [],
}
exon_counts = stats.get("exon_counts", {})
gene_lengths = stats.get("gene_lengths", {})
if exon_counts:
metrics["avg_exon_count_per_gene"] = round(sum(exon_counts.values()) / len(exon_counts), 2)
if gene_lengths:
lengths = sorted(int(v) for v in gene_lengths.values() if int(v) > 0)
metrics["avg_gene_length_bp"] = round(sum(lengths) / len(lengths), 2)
metrics["avg_cds_length_bp"] = round(metrics["avg_exon_count_per_gene"] * 100, 2)
mid = len(lengths) // 2
if len(lengths) % 2:
median_len = lengths[mid]
else:
median_len = (lengths[mid - 1] + lengths[mid]) / 2
metrics["median_gene_length_bp"] = round(median_len, 2)
metrics["max_gene_length_bp"] = max(lengths)
threshold = int(max(10000, median_len * 5))
metrics["long_gene_outlier_threshold_bp"] = threshold
ranked = sorted(gene_lengths.items(), key=lambda item: int(item[1]), reverse=True)
metrics["top_longest_gene_structures"] = [
{
"gene_id": gene_id,
"length_bp": int(length),
"cds_count": int(exon_counts.get(gene_id, 0) or 0),
}
for gene_id, length in ranked[:10]
]
metrics["long_gene_outliers"] = [
item for item in metrics["top_longest_gene_structures"]
if int(item.get("length_bp", 0)) >= threshold
]
metrics["long_gene_outlier_count"] = sum(1 for _gene_id, length in gene_lengths.items() if int(length) >= threshold)
return metrics
def run_r_plotting(tree_file: Path, structure_tsv: Path, output_prefix: Path,
rscript_path: Optional[Path] = None, cpu: int = 4,
subfamily_file: Optional[Path] = None,
project_root: Optional[Path] = None) -> Dict:
"""运行 R 绘图脚本"""
if rscript_path is None:
rscript_path = Path(__file__).parent.parent.parent / "scripts" / "plot_structure.R"
if not rscript_path.exists():
return {"success": False, "error": f"R script not found: {rscript_path}"}
rscript_exe = Path("/home/xzq/miniconda3/envs/test4py311/bin/Rscript")
if not rscript_exe.exists():
rscript_exe = Path("Rscript")
cmd = [
str(rscript_exe), str(rscript_path),
"--tree", str(tree_file),
"--structure", str(structure_tsv),
"--output", str(output_prefix)
]
# 传递项目根目录,让 R 脚本自动推断 job_id 和 subfamily
if project_root and project_root.exists():
cmd.extend(["--root", str(project_root)])
# 如果指定了亚家族文件,传递给 R 脚本
if subfamily_file and subfamily_file.exists():
cmd.extend(["--subfamily", str(subfamily_file)])
print(f"[CMD] {' '.join(cmd)}")
start_time = datetime.now()
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=600)
end_time = datetime.now()
success = result.returncode == 0
output_files = []
for ext in ['.png', '.pdf']:
f = Path(f"{output_prefix}{ext}")
if f.exists():
output_files.append(str(f))
return {
"success": success and len(output_files) > 0,
"returncode": result.returncode,
"stdout": result.stdout[-2000:],
"stderr": result.stderr[-2000:],
"output_files": output_files,
"elapsed_sec": (end_time - start_time).total_seconds()
}
except subprocess.TimeoutExpired:
return {"success": False, "error": "R plotting timeout (>600s)", "elapsed_sec": 600}
except Exception as e:
return {"success": False, "error": str(e), "elapsed_sec": 0}
def generate_report(prefix: str, metrics: Dict, parameters: Dict,
output_file: Path, artifacts: Dict, plot_success: bool) -> None:
"""生成 structure_report.md 报告"""
report_lines = [
f"# Structure Analysis Report: {prefix}",
"",
f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"**Status:** {'Success' if plot_success else 'Partial Success (Plotting Failed)'}",
"",
"## Summary",
"",
f"- **Total Genes:** {metrics['genes_total']}",
f"- **Genes with Structure:** {metrics['genes_with_structure']}",
f"- **Structure Coverage:** {metrics['structure_coverage_rate']*100:.1f}%",
f"- **Total CDS Features:** {metrics['total_cds_features']}",
f"- **Total UTR Features:** {metrics['total_utr_features']}",
"",
"## Structure Statistics",
"",
f"- **Average Exons per Gene:** {metrics['avg_exon_count_per_gene']}",
f"- **Average Gene Length:** {metrics['avg_gene_length_bp']:,.0f} bp",
f"- **Median Gene Length:** {metrics.get('median_gene_length_bp', 0):,.0f} bp",
f"- **Maximum Gene Length:** {metrics.get('max_gene_length_bp', 0):,.0f} bp",
f"- **Average CDS Length (est.):** {metrics['avg_cds_length_bp']:,.0f} bp",
"",
"## Structure Length QC",
"",
f"- **Long-gene outlier threshold:** {metrics.get('long_gene_outlier_threshold_bp', 0):,.0f} bp (max of 10 kb or 5x median gene span)",
f"- **Long-gene outliers:** {metrics.get('long_gene_outlier_count', 0)}",
"",
]
top_longest = metrics.get("top_longest_gene_structures") or []
if top_longest:
report_lines.extend([
"| Rank | Gene ID | Structure span (bp) | CDS count |",
"| --- | --- | ---: | ---: |",
])
for idx, item in enumerate(top_longest[:10], start=1):
report_lines.append(
f"| {idx} | `{item.get('gene_id', '')}` | {int(item.get('length_bp', 0)):,} | {int(item.get('cds_count', 0))} |"
)
report_lines.append("")
if metrics.get("long_gene_outliers"):
report_lines.append("**QC note:** One or more gene structures exceed the long-gene threshold. Inspect the source GFF3 before biological interpretation.")
report_lines.append("")
else:
report_lines.append("**QC note:** No gene structure exceeds the long-gene threshold; visually long tracks are usually due to real intron spacing relative to compact genes.")
report_lines.append("")
report_lines.extend([
"## Parameters",
"",
f"- **GFF3 Source:** {parameters.get('gff3_source', 'N/A')}",
f"- **GFF3 Naming:** {parameters.get('gff3_naming_convention', 'N/A')}",
f"- **Subfamily File:** {parameters.get('subfamily_file', 'Not provided')}",
f"- **CPU Cores:** {parameters.get('cpu', 'N/A')}",
f"- **Tree File:** {parameters.get('tree_file', 'N/A')}",
f"- **Gene ID File:** {parameters.get('gene_id_file', 'N/A')}",
"",
"## Output Files",
""
])
for key, artifact in artifacts.items():
if isinstance(artifact, dict):
report_lines.append(f"- **{key}:** `{artifact.get('path', 'N/A')}` ({artifact.get('type', 'unknown')})")
report_lines.extend([
"",
"## Visualization",
""
])
if plot_success:
report_lines.append("Gene structure visualization was successfully generated with three panels:")
report_lines.append("1. **Phylogenetic Tree** - Gene relationships with subfamily coloring")
report_lines.append("2. **Subfamily Bars** - Visual connection between tree and structure")
report_lines.append("3. **Gene Structure** - CDS (green) and UTR (orange) architecture")
else:
report_lines.append("**Warning:** Gene structure visualization failed. See logs for details.")
report_lines.extend([
"",
"---",
"*Generated by Gene Family Analysis Pipeline - Structure Stage*"
])
with open(output_file, 'w') as f:
f.write('\n'.join(report_lines))
print(f"[INFO] Report generated: {output_file}")
def write_manifest(manifest: Dict, manifest_file: Path) -> None:
"""写入 manifest 文件"""
with open(manifest_file, 'w') as f:
json.dump(manifest, f, indent=2)
print(f"[INFO] Manifest: {manifest_file}")
def write_failure_manifest(workspace: Path, final_dir: Path, prefix: str,
job_id: str, run_id: str, start_time: datetime,
error_msg: str, partial_artifacts: Dict = None) -> None:
"""写入失败状态的 manifest"""
end_time = datetime.now()
manifest = {
"manifest_version": "1.0",
"job_id": job_id,
"stage": "structure",
"run_id": run_id,
"prefix": prefix,
"timestamp": end_time.isoformat(),
"status": "failed",
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"elapsed_sec": (end_time - start_time).total_seconds(),
"cmd": [str(Path(__file__).name)],
"workspace_root": str(workspace.resolve()),
"workspace": str(final_dir.resolve()),
"input_dir": str(workspace / "input"),
"artifacts": partial_artifacts or {},
"parameters": {
"stage": "structure",
"error": error_msg
},
"metrics": {
"error_type": "execution_failed"
}
}
manifest_file = final_dir / f"{prefix}.structure.manifest.json"
write_manifest(manifest, manifest_file)
print(f"[ERROR] Failure manifest written: {manifest_file}")
def main():
parser = argparse.ArgumentParser(description='Structure stage - Gene structure visualization')
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('--gff3', help='Input GFF3 file (auto-detect if not specified, naming: {species_short}_final.gff3)')
parser.add_argument('--scope-file', help='Gene-family scope JSON from resolve_scope.py')
parser.add_argument('--analysis-route', default='', help='Explicit analysis route identity for manifest/status')
parser.add_argument('--member-set-id', default='', help='Explicit member-set identity for manifest/status')
parser.add_argument('--tree', help='Phylogeny tree file; auto-detects scoped phylogeny manifest when omitted')
parser.add_argument('--subfamily', help='Subfamily classification file (explicit path; auto-detect only uses confirmed_subfamily.tsv)')
parser.add_argument('--cpu', type=int, default=4, help='CPU cores for processing')
parser.add_argument('--log-file', help='Log file path')
parser.add_argument('--run-id', help='Run ID (auto-generated if not provided)')
args = parser.parse_args()
run_id = args.run_id or str(uuid.uuid4())[:8]
job_id = Path(args.workspace).name
start_time = datetime.now()
ws = Path(args.workspace)
# 1. 创建标准目录结构(契约要求)
structure_dir = ws / "structure" / "analysis"
structure_dir.mkdir(parents=True, exist_ok=True)
final_dir = ws / "final"
final_dir.mkdir(parents=True, exist_ok=True)
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}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, f"Scope file not found: {args.scope_file}")
sys.exit(1)
scope_data = read_scope(scope_file)
scope_id = str((scope_data or {}).get("scope_id") or "").strip()
output_prefix = user_output_prefix_for(
"structure",
args.prefix,
scope_data or {},
analysis_route=args.analysis_route,
member_set_id=args.member_set_id,
) if scope_id else args.prefix
# 2. 解析输入文件路径(支持自动查找和命名规范)
try:
if scope_data:
gff3_sources = resolve_scope_gff3_paths(scope_data, ws=ws)
gff3_file = gff3_sources[0][1]
else:
gff3_file = resolve_gff3_path(ws, args.prefix, args.gff3)
species_short_for_gff = args.prefix.split('.')[0] if '.' in args.prefix else args.prefix
gff3_sources = [(species_short_for_gff, gff3_file)]
except FileNotFoundError as e:
print(f"[ERROR] {e}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, str(e))
sys.exit(1)
# 亚家族文件(可选)
subfamily_file = resolve_subfamily_file(ws, args.prefix, args.subfamily, output_prefix=output_prefix, scope_data=scope_data)
# 其他必需输入
phase = "subfamily_evidence_required" if args.analysis_route == "subfamily_evidence" else "post_confirm_required"
try:
resolved_members = resolve_effective_members(
workspace=ws,
final_dir=final_dir,
input_dir=current_input_dir(ws),
prefix=args.prefix,
phase=phase, # type: ignore[arg-type]
require="id",
member_set_id=args.member_set_id,
)
gene_id_file = resolved_members.path_id or Path("")
print(f"[INFO] Gene ID file from {resolved_members.source_kind}: {gene_id_file}")
except MemberResolutionError as e:
if args.analysis_route == "subfamily_evidence":
error_msg = f"Complete identify member review before Structure subfamily evidence: {e}"
else:
error_msg = f"Complete subfamily confirmation before regular Structure analysis: {e}"
print(f"[ERROR] {error_msg}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, error_msg)
sys.exit(1)
try:
tree_file = resolve_scope_tree_file(ws, args.prefix, scope_data, args.tree)
except FileNotFoundError as e:
print(f"[ERROR] {e}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, str(e))
sys.exit(1)
if scope_data:
target_species_set = {str(item) for item in scope_data.get("target_species", []) if str(item).strip()}
tree_ids = []
for item in read_tree_order(tree_file):
if not item:
continue
if "|" in item:
species_code, gene_code = item.split("|", 1)
if target_species_set and species_code not in target_species_set:
continue
tree_ids.append(gene_code)
else:
tree_ids.append(item)
if tree_ids:
gene_id_file = structure_dir / f"{output_prefix}.scope_members.id"
with gene_id_file.open("w", encoding="utf-8") as handle:
for gene_id in tree_ids:
handle.write(gene_id + "\n")
# 验证其他输入
missing = []
if not gene_id_file.exists():
missing.append(f"Gene ID file: {gene_id_file}")
if missing:
error_msg = "Missing required input files:\n" + "\n".join(missing)
print(f"[ERROR] {error_msg}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, error_msg)
sys.exit(1)
# 提取物种简写用于报告
species_short = args.prefix.split('.')[0] if '.' in args.prefix else args.prefix
gff3_naming = (
"scope target GFF3: " + ", ".join(f"{species}:{path.name}" for species, path in gff3_sources)
if scope_data else f"{species_short}_final.gff3"
)
print(f"[INFO] Structure stage started for {args.prefix}")
print(f"[INFO] Job ID: {job_id}, Run ID: {run_id}")
print(f"[INFO] GFF3 naming convention: {gff3_naming}")
print(f"[INFO] GFF3 source: {gff3_file}")
if len(gff3_sources) > 1:
for species, source_path in gff3_sources:
print(f"[INFO] Scope GFF3 source: {species} -> {source_path}")
# 3. 读取输入数据
gene_ids = read_gene_ids(gene_id_file)
print(f"[INFO] {len(gene_ids)} genes to analyze")
tree_order = read_tree_order(tree_file)
print(f"[INFO] Tree order: {len(tree_order)} tips")
# 4. 过滤 GFF3(中间文件放入 structure/analysis/)
structure_gff3 = structure_dir / f"{output_prefix}.structure.filtered.gff3"
filter_success, filter_stats = filter_scope_gff3(gff3_sources, gene_ids, structure_gff3, structure_dir, args.prefix)
if not filter_success:
error_msg = "Failed to filter GFF3 file"
print(f"[ERROR] {error_msg}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, error_msg)
sys.exit(1)
print(f"[INFO] Filtered GFF3: {structure_gff3}")
print(f"[INFO] - Total lines: {filter_stats['total_lines']}")
print(f"[INFO] - Filtered lines: {filter_stats['filtered_lines']}")
print(f"[INFO] - Genes found: {filter_stats['genes_found']}")
# 5. 转换为 TSV(中间文件放入 structure/analysis/)
structure_tsv = structure_dir / f"{output_prefix}.structure.tsv"
convert_success, convert_stats = gff3_to_tsv(structure_gff3, structure_tsv, tree_order, gene_ids)
if not convert_success:
error_msg = "Failed to convert GFF3 to TSV (no valid features found)"
print(f"[ERROR] {error_msg}")
write_failure_manifest(ws, final_dir, args.prefix, job_id, run_id, start_time, error_msg)
sys.exit(1)
print(f"[INFO] Structure TSV: {structure_tsv}")
print(f"[INFO] - Genes with structure: {convert_stats['genes_with_structure']}")
print(f"[INFO] - Total CDS: {convert_stats['total_cds']}")
print(f"[INFO] - Total UTR: {convert_stats['total_utr']}")
# 6. 计算结构 metrics
structure_metrics = calculate_structure_metrics(convert_stats, len(gene_ids))
print(f"[INFO] Metrics calculated:")
for k, v in structure_metrics.items():
print(f" - {k}: {v}")
# 7. R 绘图(输出到 final/)
output_path_prefix = final_dir / f"{output_prefix}.structure"
project_root = Path(__file__).parent.parent.parent # bbav5 根目录
plot_result = run_r_plotting(
tree_file,
structure_tsv,
output_path_prefix,
cpu=args.cpu,
subfamily_file=subfamily_file,
project_root=project_root
)
plot_success = plot_result.get("success", False)
if not plot_success:
print(f"[WARNING] R plotting failed: {plot_result.get('error', plot_result.get('stderr', 'unknown'))}")
print(f"[INFO] Plot files: {plot_result.get('output_files', [])}")
# 8. 准备 artifacts
end_time = datetime.now()
artifacts = {}
# 中间文件
if structure_gff3.exists():
artifacts["structure_gff3_filtered"] = {
"artifact": "structure_gff3_filtered",
"path": str(structure_gff3.relative_to(ws)),
"full_path": display_path(structure_gff3),
"type": "gff3",
"size": structure_gff3.stat().st_size,
"sha256": compute_file_hash(structure_gff3),
"description": "Filtered GFF3 with only target genes (intermediate)"
}
if structure_tsv.exists():
artifacts["structure_tsv"] = {
"artifact": "structure_tsv",
"path": str(structure_tsv.relative_to(ws)),
"full_path": display_path(structure_tsv),
"type": "tsv",
"size": structure_tsv.stat().st_size,
"description": "Gene structure data in TSV format (intermediate)"
}
# 最终产物
for img_file in plot_result.get("output_files", []):
img_path = Path(img_file)
if img_path.exists():
key = f"structure_plot_{img_path.suffix[1:]}"
artifacts[key] = {
"artifact": "structure_plot",
"path": str(img_path.relative_to(ws)),
"full_path": display_path(img_path),
"type": "image",
"format": img_path.suffix[1:],
"size": img_path.stat().st_size,
"sha256": compute_file_hash(img_path),
"description": f"Gene structure visualization ({img_path.suffix[1:].upper()})"
}
# 9. 生成报告
report_file = final_dir / f"{output_prefix}.structure_report.md"
parameters = {
"cpu": args.cpu,
"prefix": args.prefix,
"output_prefix": output_prefix,
"stage": "structure",
"job_id": job_id,
"run_id": run_id,
"gff3_source": str(gff3_file),
"gff3_sources": [{"species": species, "path": str(path)} for species, path in gff3_sources],
"gff3_naming_convention": gff3_naming,
"gff3_source_sha256": compute_file_hash(gff3_file),
"subfamily_file": str(subfamily_file) if subfamily_file else None,
"tree_file": str(tree_file),
"gene_id_file": str(gene_id_file),
"gene_count": len(gene_ids),
"tree_tip_count": len(tree_order),
"Rscript_path": str(Path(__file__).parent.parent.parent / "scripts" / "plot_structure.R"),
"tool_versions": get_tool_versions(),
"timestamp_start": start_time.isoformat(),
"timestamp_end": end_time.isoformat(),
"workspace_root": display_path(ws),
"input_dir": str(ws / "input"),
"structure_analysis_dir": str(structure_dir),
"final_dir": str(final_dir)
}
generate_report(output_prefix, structure_metrics, parameters, report_file, artifacts, plot_success)
if report_file.exists():
artifacts["structure_report"] = {
"artifact": "structure_report",
"path": str(report_file.relative_to(ws)),
"full_path": display_path(report_file),
"type": "report",
"format": "markdown",
"size": report_file.stat().st_size,
"sha256": compute_file_hash(report_file),
"description": "Human-readable analysis report"
}
# 10. 确定状态
status = "success" if plot_success else "partial_success"
# 11. 构建完整 manifest
manifest = {
"manifest_version": "1.0",
"job_id": job_id,
"stage": "structure",
"run_id": run_id,
"prefix": args.prefix,
"output_prefix": output_prefix,
"timestamp": end_time.isoformat(),
"status": status,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"elapsed_sec": (end_time - start_time).total_seconds(),
"cmd": [
str(Path(__file__).name),
"--prefix", args.prefix,
"--workspace", str(ws),
"--gff3", str(gff3_file),
"--cpu", str(args.cpu),
"--run-id", run_id
] + (["--scope-file", str(scope_file)] if scope_file else []) + (["--subfamily", str(subfamily_file)] if subfamily_file else []),
"workspace_root": display_path(ws),
"workspace": display_path(structure_dir),
"input_dir": str(ws / "input"),
"artifacts": artifacts,
"parameters": parameters,
"metrics": structure_metrics
}
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", []),
}
context = build_stage_context(
stage="structure",
prefix=args.prefix,
scope_data=scope_data or {},
scope_file=scope_file or "",
output_prefix=output_prefix,
subfamily_file=subfamily_file,
analysis_route=args.analysis_route,
member_set_id=args.member_set_id,
)
manifest["tree_source"] = str(tree_file)
manifest["gene_id_file"] = str(gene_id_file)
manifest["gff3_source"] = str(gff3_file)
apply_stage_context(manifest, context)
manifest_file = final_dir / f"{output_prefix}.structure.manifest.json"
write_manifest(manifest, manifest_file)
if scope_data and scope_data.get("scope_id"):
write_legacy_manifest_copy(manifest_file, args.prefix, str(scope_data.get("scope_id") or ""), "structure")
# 12. 完成
if status == "success":
print(f"[INFO] Structure stage completed successfully")
else:
print(f"[WARNING] Structure stage completed with partial success (plotting failed)")
print("\n=== OUTPUT_FILES ===")
for k, v in artifacts.items():
if isinstance(v, dict) and v.get("type") == "image":
print(f"IMAGE: {v['full_path']}")
sys.exit(0 if status == "success" else 1)
if __name__ == "__main__":
main()