Files
PlantAI-dev/apps/cli/run_gwas_trait.py
T

366 lines
13 KiB
Python
Raw Normal View History

2026-08-03 18:42:47 +08:00
#!/usr/bin/env python3
"""Validate one phenotype and write a TASSEL trait table."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import math
import os
import platform
import shlex
import shutil
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from gfa_core.gwas_scientific_identity import (
build_identity_recipe,
build_phenotype_scope_id,
materialize_stage_identity,
new_run_id,
)
STAGE = "gwas_trait"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _write_json(path: Path, value: dict[str, Any]) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _read_phenotype(
path: Path,
sample_column: str,
trait_column: str,
) -> tuple[list[str], list[str], list[float]]:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle, delimiter="\t")
columns = reader.fieldnames or []
if sample_column not in columns:
raise ValueError(f"selected sample column does not exist: {sample_column}")
if trait_column not in columns:
raise ValueError(f"selected trait column does not exist: {trait_column}")
sample_ids: list[str] = []
trait_texts: list[str] = []
trait_values: list[float] = []
for row in reader:
sample_id = str(row.get(sample_column) or "").strip()
trait_text = str(row.get(trait_column) or "").strip()
if not sample_id:
raise ValueError("sample IDs must be nonempty")
if not trait_text:
raise ValueError("missing values are not allowed")
try:
trait_value = float(trait_text)
except ValueError as exc:
raise ValueError("trait values must be numeric") from exc
if not math.isfinite(trait_value):
raise ValueError("trait values must be finite")
sample_ids.append(sample_id)
trait_texts.append(trait_text)
trait_values.append(trait_value)
if not sample_ids:
raise ValueError("phenotype table contains no data rows")
if len(sample_ids) != len(set(sample_ids)):
raise ValueError("sample IDs must be unique")
if min(trait_values) == max(trait_values):
raise ValueError("trait variance must be positive")
return sample_ids, trait_texts, trait_values
def _artifact(workspace: Path, path: Path, role: str, media_type: str) -> dict[str, Any]:
return {
"artifact": role,
"role": role,
"path": str(path.relative_to(workspace)),
"full_path": str(path.resolve()),
"type": media_type,
"size": path.stat().st_size,
"size_bytes": path.stat().st_size,
"sha256": sha256_file(path),
}
def _runner_command(args: argparse.Namespace, workspace: Path, phenotype: Path) -> list[str]:
command = [
"python",
"-m",
"apps.cli.run_gwas_trait",
"--workspace",
str(workspace),
"--job-id",
args.job_id,
"--prefix",
args.prefix,
"--phenotype",
str(phenotype),
"--sample-column",
args.sample_column,
"--trait-column",
args.trait_column,
"--trait-name",
args.trait_name,
]
if args.log_file:
command.extend(["--log-file", str(args.log_file)])
return command
def _write_reproducibility(
workspace: Path,
identity12: str,
run_id: str,
command: list[str],
manifest_path: Path,
manifest: dict[str, Any],
environment_text: str,
) -> None:
bundle = workspace / "reproducibility" / STAGE / f"{manifest['prefix']}.{identity12}" / run_id
bundle.mkdir(parents=True, exist_ok=True)
canonical = shlex.join(command)
(bundle / "commands.sh").write_text(
"#!/usr/bin/env bash\nset -euo pipefail\n" + canonical + "\n",
encoding="utf-8",
)
(bundle / "environment.txt").write_text(environment_text, encoding="utf-8")
_write_json(
bundle / "progress.json",
{
"stage": STAGE,
"status": manifest["status"],
"run_id": run_id,
"manifest_path": str(manifest_path.relative_to(workspace)),
},
)
_write_json(
bundle / "provenance.json",
{
"stage": STAGE,
"run_id": run_id,
"runner_command": canonical,
"software": manifest.get("software", {}),
"artifacts": manifest.get("artifacts", {}),
},
)
targets = [manifest_path]
for artifact in manifest.get("artifacts", {}).values():
candidate = workspace / artifact["path"]
if candidate.is_file():
targets.append(candidate)
(bundle / "checksums.sha256").write_text(
"".join(
f"{sha256_file(path)} {path.relative_to(workspace)}\n"
for path in sorted(targets)
),
encoding="utf-8",
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workspace", required=True, type=Path)
parser.add_argument("--job-id", required=True)
parser.add_argument("--prefix", required=True)
parser.add_argument("--phenotype", required=True, type=Path)
parser.add_argument("--sample-column", required=True)
parser.add_argument("--trait-column", required=True)
parser.add_argument("--trait-name", required=True)
parser.add_argument("--log-file", type=Path)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
workspace = args.workspace.resolve()
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
phenotype = args.phenotype.resolve()
run_id = new_run_id(STAGE)
started = datetime.now(timezone.utc)
started_clock = time.monotonic()
failed_manifest = final_dir / f"{args.prefix}.{STAGE}.failed.manifest.json"
command = _runner_command(args, workspace, phenotype)
manifest: dict[str, Any] = {
"manifest_version": "1.0",
"stage": STAGE,
"prefix": args.prefix,
"job_id": args.job_id,
"run_id": run_id,
"status": "failed",
"timestamp": started.isoformat(),
"start_time": started.isoformat(),
"end_time": started.isoformat(),
"elapsed_sec": 0.0,
"artifacts": {},
"cmd": command,
"workspace_root": str(workspace),
"workspace": str(final_dir),
"input_dir": str(workspace / "input"),
}
try:
if not phenotype.is_file():
raise FileNotFoundError("Authoritative phenotype table does not exist")
if not args.sample_column.strip():
raise ValueError("sample column must be explicit")
if not args.trait_column.strip():
raise ValueError("trait column must be explicit")
trait_name = args.trait_name.strip()
if not trait_name or any(character in trait_name for character in "\t\r\n"):
raise ValueError("trait name must be a nonempty single field")
phenotype_sha = sha256_file(phenotype)
sample_ids, trait_texts, trait_values = _read_phenotype(
phenotype, args.sample_column, args.trait_column
)
phenotype_scope = build_phenotype_scope_id(
phenotype_sha256=phenotype_sha,
sample_column=args.sample_column,
trait_column=args.trait_column,
trait_name=trait_name,
retained_sample_ids=sample_ids,
)
parameters = {
"sample_column": args.sample_column,
"trait_column": args.trait_column,
"trait_name": trait_name,
"missing_policy": "reject",
}
python_version = platform.python_version()
recipe = build_identity_recipe(
stage=STAGE,
required_artifact_roles=["phenotype_table"],
parameters=parameters,
method_version="tassel_trait_format.v1",
contract_version="v1",
tool_versions={"python": python_version},
)
identity = materialize_stage_identity(
recipe,
{"phenotype_table": {"sha256": phenotype_sha}},
{"phenotype_scope_id": phenotype_scope},
)
identity12 = identity["analysis_scope_id"].split(":", 1)[1][:12]
attempt_dir = workspace / ".attempts" / run_id
attempt_dir.mkdir(parents=True, exist_ok=False)
trait_work = attempt_dir / "trait.txt"
trait_work.write_text(
f"<Trait>\t{trait_name}\n"
+ "".join(
f"{sample_id}\t{trait_text}\n"
for sample_id, trait_text in zip(sample_ids, trait_texts)
),
encoding="utf-8",
)
stem = f"{args.prefix}.{STAGE}.{identity12}"
trait_final = final_dir / f"{stem}.trait.txt"
environment_final = final_dir / f"{stem}.environment.txt"
report_final = final_dir / f"{stem}.report.md"
manifest_path = final_dir / f"{stem}.manifest.json"
shutil.copy2(trait_work, trait_final)
environment_text = "\n".join(
[
"runner=apps.cli.run_gwas_trait.py",
f"runtime_environment={os.environ.get('CONDA_DEFAULT_ENV', 'unknown')}",
f"python_resolved={Path(sys.executable).resolve()}",
f"python_version={python_version}",
f"platform={platform.platform()}",
]
) + "\n"
environment_final.write_text(environment_text, encoding="utf-8")
report_final.write_text(
"# GWAS phenotype preparation\n\n"
f"- Phenotype scope: `{phenotype_scope}`\n"
f"- Analysis scope: `{identity['analysis_scope_id']}`\n"
f"- Sample column: `{args.sample_column}`\n"
f"- Trait column: `{args.trait_column}`\n"
f"- Trait name: `{trait_name}`\n"
"- Missing policy: `reject`\n"
f"- Samples: {len(sample_ids)}\n",
encoding="utf-8",
)
mean = sum(trait_values) / len(trait_values)
variance = sum((value - mean) ** 2 for value in trait_values) / len(trait_values)
manifest.update(
{
"status": "success",
"phenotype_scope_id": phenotype_scope,
"analysis_scope_id": identity["analysis_scope_id"],
"identity_recipe": recipe,
"inputs": {
"phenotype_table": {
"path": str(phenotype),
"size": phenotype.stat().st_size,
"sha256": phenotype_sha,
}
},
"parameters": parameters,
"metrics": {
"sample_count": len(sample_ids),
"sample_order_conservation": True,
"value_pair_conservation": True,
"trait_numeric_finite": True,
"trait_variance_positive": True,
"trait_variance": variance,
},
"software": {
"python": {
"resolved_path": str(Path(sys.executable).resolve()),
"version": python_version,
}
},
"artifacts": {
"trait_table": _artifact(
workspace, trait_final, "trait_table", "text/tab-separated-values"
),
"environment": _artifact(
workspace, environment_final, "environment", "text/plain"
),
"report": _artifact(workspace, report_final, "report", "text/markdown"),
},
}
)
manifest["end_time"] = datetime.now(timezone.utc).isoformat()
manifest["elapsed_sec"] = round(time.monotonic() - started_clock, 6)
_write_json(manifest_path, manifest)
failed_manifest.unlink(missing_ok=True)
_write_reproducibility(
workspace,
identity12,
run_id,
command,
manifest_path,
manifest,
environment_text,
)
return 0
except Exception as exc:
manifest["error"] = str(exc)
manifest["end_time"] = datetime.now(timezone.utc).isoformat()
manifest["elapsed_sec"] = round(time.monotonic() - started_clock, 6)
_write_json(failed_manifest, manifest)
if args.log_file:
args.log_file.parent.mkdir(parents=True, exist_ok=True)
args.log_file.write_text(f"ERROR: {exc}\n", encoding="utf-8")
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())