Files
PlantAI-dev/gfa_core/mas/subfamily_status.py
2026-08-03 18:42:47 +08:00

377 lines
15 KiB
Python
Executable File

"""Shared subfamily label state resolver for gene-family workflows."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import json
def _read_first_column_ids(path: str | Path) -> set[str]:
ids: set[str] = set()
if not path:
return ids
try:
with open(path, "r", encoding="utf-8") as handle:
first = True
for raw in handle:
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t") if "\t" in line else line.split()
if not parts:
continue
token = parts[0].strip()
if first and token.lower() in {"gene_id", "gene", "id"}:
first = False
continue
first = False
if token:
ids.add(token)
except Exception:
return set()
return ids
def _first_existing(paths: list[Path]) -> str:
seen: set[Path] = set()
for path in paths:
if path in seen:
continue
seen.add(path)
try:
if path.exists() and path.stat().st_size > 0:
return str(path)
except OSError:
continue
return ""
def _manifest_classifiable_species(manifest_path: Path) -> list[str]:
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
return []
species: list[str] = []
for key in ("classifiable_species", "target_species", "comparative_species"):
for item in data.get(key) or []:
item = str(item or "").strip()
if item and item not in species:
species.append(item)
metrics = data.get("metrics") if isinstance(data.get("metrics"), dict) else {}
for item in (metrics.get("scope_target_member_counts") or {}).keys():
item = str(item or "").strip()
if item and item not in species:
species.append(item)
return species
def _joint_subfamily_candidates(final_dir: Path, species: str, family: str, suffix: str) -> list[Path]:
"""Return joint subfamily outputs whose manifest declares species as classifiable."""
if not final_dir.exists() or not species or not family:
return []
candidates: list[Path] = []
for manifest_path in sorted(
final_dir.glob(f"Target.{family}*.reference_classification.manifest.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
):
if species not in _manifest_classifiable_species(manifest_path):
continue
output_prefix = manifest_path.name[: -len(".reference_classification.manifest.json")]
for candidate in (
final_dir / f"{output_prefix}{suffix}",
final_dir / f"{output_prefix.replace('.reference_classification', '')}{suffix}",
):
if candidate not in candidates:
candidates.append(candidate)
for pattern in (
f"Target.{family}*.comp-*{species}*{suffix}",
f"Target.{family}*.target-*{species}*{suffix}",
):
candidates.extend(sorted(final_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True))
return candidates
def target_member_files(workspace_root: str | Path, job_id: str, species: str, family: str) -> dict[str, str]:
final_dir = Path(workspace_root) / job_id / "final" if job_id else Path("")
if not final_dir.exists() or not species or not family:
return {}
candidates = {
"pep": [
final_dir / f"{species}.{family}.curated_members.pep.fa",
final_dir / f"Target.{species}.{family}.curated_members.pep.fa",
final_dir / f"{species}.{family}.final_members.pep.fa",
final_dir / f"Target.{species}.{family}.final_members.pep.fa",
],
"id": [
final_dir / f"{species}.{family}.curated_members.id",
final_dir / f"Target.{species}.{family}.curated_members.id",
final_dir / f"{species}.{family}.final_members.id",
final_dir / f"Target.{species}.{family}.final_members.id",
],
}
return {key: found for key, paths in candidates.items() if (found := _first_existing(paths))}
def uploaded_target_subfamily_file(
workspace_root: str | Path,
input_root: str | Path,
job_id: str,
species: str,
family: str,
) -> str:
if not job_id or not species or not family:
return ""
final_dir = Path(workspace_root) / job_id / "final"
input_dir = Path(input_root) / job_id
return _first_existing([
input_dir / f"{species}.{family}.subfamily",
input_dir / f"{species}.{family}.subfamily.tsv",
final_dir / f"{species}.{family}.subfamily",
final_dir / f"{species}.{family}.subfamily.tsv",
])
def confirmed_subfamily_file(
workspace_root: str | Path,
input_root: str | Path,
job_id: str,
species: str,
family: str,
) -> str:
if not job_id or not species or not family:
return ""
final_dir = Path(workspace_root) / job_id / "final"
input_dir = Path(input_root) / job_id
candidates = [
final_dir / f"{species}.{family}.confirmed_subfamily.tsv",
input_dir / f"{species}.{family}.confirmed_subfamily.tsv",
final_dir / f"{species}.{family}.target_plus_reference.confirmed_subfamily.tsv",
input_dir / f"{species}.{family}.target_plus_reference.confirmed_subfamily.tsv",
]
for pattern in (
f"Target.{family}.target-{species}*.subfamily_prediction.confirmed_subfamily.tsv",
f"Target.{family}.target-{species}*.confirmed_subfamily.tsv",
f"*target-{species}*.subfamily_prediction.confirmed_subfamily.tsv",
):
candidates.extend(sorted(final_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True))
candidates.extend(_joint_subfamily_candidates(final_dir, species, family, ".confirmed_subfamily.tsv"))
return _first_existing(candidates)
def prediction_subfamily_file(workspace_root: str | Path, job_id: str, species: str, family: str) -> str:
if not job_id or not species or not family:
return ""
final_dir = Path(workspace_root) / job_id / "final"
candidates = [
final_dir / f"{species}.{family}.target_plus_reference.reference_classification.subfamily.tsv",
final_dir / f"{species}.{family}.reference_classification.subfamily.tsv",
final_dir / f"{species}.{family}.{family}.target-{species}.ref-At.target_plus_reference.reference_classification.subfamily.tsv",
]
for pattern in (
f"{species}.{family}*.reference_classification.subfamily.tsv",
f"*target-{species}*.reference_classification.subfamily.tsv",
):
candidates.extend(sorted(final_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True))
candidates.extend(_joint_subfamily_candidates(final_dir, species, family, ".reference_classification.subfamily.tsv"))
return _first_existing(candidates)
def reference_subfamily_file(
workspace_root: str | Path,
input_root: str | Path,
job_id: str,
species: str,
family: str,
) -> str:
if not job_id or not species or not family:
return ""
final_dir = Path(workspace_root) / job_id / "final"
input_dir = Path(input_root) / job_id
return _first_existing([
input_dir / f"{species}.{family}.subfamily",
input_dir / f"{species}.{family}.subfamily.tsv",
input_dir / f"{species}.{family}.geneID.subfamily",
final_dir / f"{species}.{family}.subfamily",
final_dir / f"{species}.{family}.subfamily.tsv",
final_dir / f"{species}.{family}.geneID.subfamily",
])
def _label_file_status(
status_name: str,
path: str,
member_ids: set[str],
species: str,
family: str,
) -> dict[str, Any]:
label_ids = _read_first_column_ids(path)
missing = sorted(member_ids - label_ids)
extra = sorted(label_ids - member_ids)
state: dict[str, Any] = {
"species": species,
"family": family,
"status": status_name,
"label_file": path,
"label_count": len(label_ids),
"missing_member_ids": missing[:20],
"extra_label_ids": extra[:20],
"issues": [],
}
if missing:
state["status"] = "confirmed_mismatch"
state["issues"].append({
"severity": "error",
"code": "subfamily_label_member_mismatch",
"message": f"{Path(path).name} 未覆盖当前 {species}/{family} 目标成员集合。",
"suggestion": "请重新确认亚家族标签,或检查当前成员集合是否已更新。",
"missing_count": len(missing),
"extra_reference_or_non_target_count": len(extra),
})
return state
def subfamily_state_for_species(
workspace_root: str | Path,
input_root: str | Path,
job_id: str,
species: str,
family: str,
role: str = "target",
) -> dict[str, Any]:
role = str(role or "target").strip().lower()
reference_label = reference_subfamily_file(workspace_root, input_root, job_id, species, family)
if role == "reference":
return {
"species": species,
"family": family,
"role": role,
"reference_subfamily_file": reference_label,
"status": "reference_label_available" if reference_label else "reference_label_missing",
"issues": [] if reference_label else [{
"severity": "error",
"code": "missing_reference_subfamily",
"message": f"缺少参考物种 {species}.{family}.subfamily。",
"suggestion": f"请上传 {species}.{family}.subfamily 作为参考亚家族标签。",
}],
}
member_files = target_member_files(workspace_root, job_id, species, family)
member_ids = _read_first_column_ids(member_files.get("id", ""))
state: dict[str, Any] = {
"species": species,
"family": family,
"role": role,
"member_files": member_files,
"member_count": len(member_ids),
"uploaded_subfamily_file": uploaded_target_subfamily_file(workspace_root, input_root, job_id, species, family),
"confirmed_file": confirmed_subfamily_file(workspace_root, input_root, job_id, species, family),
"prediction_file": prediction_subfamily_file(workspace_root, job_id, species, family),
"reference_subfamily_file": reference_label,
"status": "unknown",
"issues": [],
}
if not member_files.get("pep") or not member_files.get("id"):
state["status"] = "missing_identify"
state["issues"].append({
"severity": "error",
"code": "missing_target_family_members",
"message": f"{species}/{family} 尚未完成基因家族成员鉴定,不能进行亚家族确认或传统基因家族分析。",
"suggestion": "请先执行 identify。",
})
return state
if state["confirmed_file"]:
state.update(_label_file_status("confirmed_valid", state["confirmed_file"], member_ids, species, family))
state["confirmed_file"] = state["label_file"]
return state
if state["uploaded_subfamily_file"]:
state.update(_label_file_status("uploaded_confirmed", state["uploaded_subfamily_file"], member_ids, species, family))
state["uploaded_subfamily_file"] = state["label_file"]
return state
if state["prediction_file"]:
state["status"] = "predicted_unconfirmed"
prediction_name = Path(state["prediction_file"]).name
if prediction_name.startswith(f"Target.{family}.") and ".reference_classification.subfamily.tsv" in prediction_name:
state["review_prefix"] = prediction_name[: -len(".reference_classification.subfamily.tsv")]
else:
state["review_prefix"] = f"{species}.{family}"
state["issues"].append({
"severity": "warning",
"code": "subfamily_prediction_unconfirmed",
"message": f"检测到 {species}/{family} 已有亚家族分类预测文件,但还没有保存确认标签。",
"suggestion": "请先打开亚家族确认表并保存。",
})
return state
state["status"] = "not_classified"
state["issues"].append({
"severity": "warning",
"code": "subfamily_classification_missing",
"message": f"{species}/{family} 尚未进行目标物种+参考物种的亚家族分类。",
"suggestion": "请先执行亚家族分类并确认标签,或上传目标物种亚家族文件。",
})
return state
def subfamily_state_for_scope(
workspace_root: str | Path,
input_root: str | Path,
job_id: str,
family: str,
target_species: list[str] | tuple[str, ...] | None = None,
reference_species: list[str] | tuple[str, ...] | None = None,
comparative_species: list[str] | tuple[str, ...] | None = None,
) -> dict[str, Any]:
targets = [str(x).strip() for x in (target_species or []) if str(x).strip()]
refs = [str(x).strip() for x in (reference_species or []) if str(x).strip()]
comps = [str(x).strip() for x in (comparative_species or []) if str(x).strip()]
label_species = []
for sp in [*targets, *comps]:
if sp and sp not in label_species:
label_species.append(sp)
by_species = {
sp: subfamily_state_for_species(workspace_root, input_root, job_id, sp, family, "target")
for sp in label_species
}
reference_by_species = {
sp: subfamily_state_for_species(workspace_root, input_root, job_id, sp, family, "reference")
for sp in refs
}
statuses = {sp: data.get("status") for sp, data in by_species.items()}
ready_statuses = {"confirmed_valid", "uploaded_confirmed"}
all_labels_ready = bool(by_species) and all(status in ready_statuses for status in statuses.values())
issues: list[dict[str, Any]] = []
for sp, data in {**by_species, **reference_by_species}.items():
for issue in data.get("issues") or []:
item = dict(issue)
item.setdefault("species", sp)
issues.append(item)
if all_labels_ready:
overall = "confirmed_valid"
elif any(status == "missing_identify" for status in statuses.values()):
overall = "missing_identify"
elif any(status == "confirmed_mismatch" for status in statuses.values()):
overall = "confirmed_mismatch"
elif any(status == "predicted_unconfirmed" for status in statuses.values()):
overall = "predicted_unconfirmed"
elif any(status == "not_classified" for status in statuses.values()):
overall = "not_classified"
else:
overall = "unknown"
return {
"overall": overall,
"by_species": by_species,
"reference_by_species": reference_by_species,
"issues": issues,
"statuses": statuses,
"all_confirmed": all_labels_ready,
"all_labels_ready": all_labels_ready,
"any_uploaded_confirmed": any(status == "uploaded_confirmed" for status in statuses.values()),
"any_confirmed_valid": any(status == "confirmed_valid" for status in statuses.values()),
"any_predicted": any(status == "predicted_unconfirmed" for status in statuses.values()),
"any_not_classified": any(status == "not_classified" for status in statuses.values()),
"label_species": label_species,
"reference_species": refs,
}