PGx: full call detail (PGX-016..020, 027/028, 031), pgx coverage (PGX-009/035), full HGVS
- supporting_variants.tsv, unresolved_variants.tsv, variants_not_covered.tsv with PGX-026 metadata; per-allele supporting/unresolved sites for both alleles of every candidate; variant_change vs top, distant_alternative, unexplained_variants, alleles_impacted_by_no_call, alleles_tested. - simurg pgx coverage: callable / not_covered / masked / equivalent_to per allele and manifest. - HGVS on GRCh38 RefSeq accessions (NC_...:g.). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
This commit is contained in:
1 parent
0215af9c9c
commit
4dafce3f18
10 files changed
+352
-40
No files matched your search
+187
-27
@@ -17,12 +17,15 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from simurg import __version__
|
||||
from simurg.core.errors import ExitCode, SimurgError
|
||||
from simurg.core.log import Reporter
|
||||
from simurg.pgx.star import CALLED, MASKED, NO_CALL, NOT_COVERED, GeneModel, Observations, Solution, call_gene
|
||||
|
||||
MAX_EDIT_DISTANCE = 3 # PGX-019
|
||||
|
||||
CN_REGION = {"CYP2D6": "CYP2D6.p5"} # PGX-013: total gene copies from the 5′ region
|
||||
DELETION_ALLELE = {"CYP2D6": "*5"}
|
||||
|
||||
@@ -145,6 +148,72 @@ def observations(
|
||||
return Observations(state, dosage, gs, baf), ploidy
|
||||
|
||||
|
||||
def variant_records(m: GeneModel, recs: dict[tuple[str, int], list[dict[str, Any]]]) -> list[dict[str, Any] | None]:
|
||||
"""The VCF record that carries each defining variant (same REF, ALT among the ALTs), or None."""
|
||||
out: list[dict[str, Any] | None] = []
|
||||
for c, p, r_, a_ in m.variants:
|
||||
out.append(next((rec for rec in recs.get((c, p), []) if rec["ref"] == r_ and a_ in rec["alts"]), None))
|
||||
return out
|
||||
|
||||
|
||||
def _vcn(obs: Observations, k: int, n: int) -> int | None:
|
||||
"""PGX-012 variant copy number: GT dosage at CN 2, else the ML k of BAF ~ N(k/n, σ²)."""
|
||||
if obs.state[k] != CALLED:
|
||||
return None
|
||||
if n == 2 or math.isnan(obs.baf[k]):
|
||||
return int(obs.dosage[k])
|
||||
return int(min(n, max(0, round(obs.baf[k] * n))))
|
||||
|
||||
|
||||
def solution_detail(m: GeneModel, obs: Observations, recs: list[dict[str, Any] | None], s: Solution, top: Solution,
|
||||
low_gs: float, max_edit: int) -> dict[str, Any]: # fmt: skip
|
||||
"""PGX-018 … PGX-020, PGX-028: per-allele supporting / unresolved variants, no-call impact, change vs top."""
|
||||
n = max(1, len(s.copies))
|
||||
alleles = []
|
||||
names = list(s.haplotypes)
|
||||
hap_of = {m.haplotypes[i]: i for i in s.copies}
|
||||
reason = {NO_CALL: "no_call", MASKED: "masked", NOT_COVERED: "not_on_array"}
|
||||
for name in names:
|
||||
core = name.split("x")[0] if "x" in name and name.split("x")[-1].isdigit() else name
|
||||
h = hap_of.get(core)
|
||||
sup, unres = [], []
|
||||
if h is not None:
|
||||
for k in np.flatnonzero(m.carries[h] == 1).tolist():
|
||||
c, p, r_, a_ = m.variants[k]
|
||||
hg, rs = m.variant_ids[k]
|
||||
rec = recs[k]
|
||||
if obs.state[k] == CALLED:
|
||||
sup.append({"chrom": c, "pos": p, "ref": r_, "alts": rec["alts"] if rec else [a_],
|
||||
"gt": rec["gt"] if rec else None, "gs": None if math.isnan(obs.gs[k]) else
|
||||
round(float(obs.gs[k]), 4), "baf": None if math.isnan(obs.baf[k]) else
|
||||
round(float(obs.baf[k]), 4), "variant_copy_number": _vcn(obs, k, n), "hgvs": hg,
|
||||
"rsid": rs, "probe_ids": rec["id"].split(",") if rec else [],
|
||||
"low_gs": bool(obs.gs[k] < low_gs)}) # fmt: skip
|
||||
else:
|
||||
unres.append({"hgvs": hg, "rsid": rs, "reason": reason[int(obs.state[k])],
|
||||
"probe_ids": rec["id"].split(",") if rec else []}) # fmt: skip
|
||||
background = None
|
||||
if "+" in name:
|
||||
background = name.split("+")[-1]
|
||||
alleles.append({"allele": name, "supporting_variants": sup, "unresolved_variant_sites": unres,
|
||||
"array_equivalent_alleles": sorted({mm[names.index(name)] for mm in s.members
|
||||
if len(mm) == len(names)}, key=_natural),
|
||||
"background_allele": background}) # fmt: skip
|
||||
exp = _expected(m, s.copies)
|
||||
exp_top = _expected(m, top.copies)
|
||||
called = obs.state == CALLED
|
||||
changed = [m.variant_ids[k][0] for k in np.flatnonzero(called & (exp != exp_top)).tolist()]
|
||||
flags = ["distant_alternative"] if len(changed) > max_edit else []
|
||||
return {"alleles": alleles, "variant_change": changed if s is not top else None, "flags": flags}
|
||||
|
||||
|
||||
def _expected(m: GeneModel, copies: tuple[int, ...]) -> npt.NDArray[np.int16]:
|
||||
e = np.zeros(m.carries.shape[1], dtype=np.int16)
|
||||
for i in copies:
|
||||
e += np.maximum(m.carries[i], 0)
|
||||
return e
|
||||
|
||||
|
||||
def read_cn_vcf(path: Path) -> dict[str, int | None]:
|
||||
out: dict[str, int | None] = {}
|
||||
with gzip.open(path, "rt") as fh:
|
||||
@@ -172,7 +241,7 @@ def _discover(dirs: list[Path]) -> tuple[dict[str, Path], dict[str, Path]]:
|
||||
def run_star_call(o: StarOptions, rep: Reporter) -> int:
|
||||
"""``simurg pgx star-allele call`` (CLI-033)."""
|
||||
from simurg.core.atomic import check_outputs_absent
|
||||
from simurg.pipeline.qc import write_tsv
|
||||
from simurg.pipeline.pgx_pheno import db_meta, tsv_header, write_meta_tsv
|
||||
|
||||
snv, cnv = _discover(o.vcfs)
|
||||
if not snv:
|
||||
@@ -195,7 +264,10 @@ def run_star_call(o: StarOptions, rep: Reporter) -> int:
|
||||
for m in models.values():
|
||||
for c, p, _, _ in m.variants:
|
||||
wanted[c].add(p)
|
||||
rows = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
sup_rows: list[dict[str, Any]] = []
|
||||
unres_rows: list[dict[str, Any]] = []
|
||||
not_cov_rows: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
calls_json = []
|
||||
for sid, path in sorted(snv.items()):
|
||||
recs, meta = read_vcf_sites(path, wanted)
|
||||
@@ -215,42 +287,130 @@ def run_star_call(o: StarOptions, rep: Reporter) -> int:
|
||||
sample_name = sid
|
||||
genes_out = []
|
||||
for gene, m in sorted(models.items()):
|
||||
obs, ploidy = observations(m, recs, masked)
|
||||
region = CN_REGION.get(gene)
|
||||
gcn = cn.get(region) if region else None
|
||||
sols, feasible = call_gene(m, obs, cn=gcn if region else None, ploidy=ploidy,
|
||||
max_candidates=o.max_candidates, feasible_ratio=o.feasible_ratio) # fmt: skip
|
||||
flags = []
|
||||
low = [k for k in range(len(m.variants)) if obs.state[k] == CALLED and obs.gs[k] < o.low_gs]
|
||||
if low:
|
||||
flags.append("review_alternatives")
|
||||
no_calls = [k for k in range(len(m.variants)) if obs.state[k] == NO_CALL]
|
||||
genotype = ";".join(s.genotype for s in feasible)
|
||||
for rank, s in enumerate(sols, start=1):
|
||||
rows.append({"sample_id": sid, "sample_name": sample_name, "solution_id": rank, "gene": gene,
|
||||
"call_type": "star_allele", "solution": s.genotype,
|
||||
"array_equivalent_alleles_1": ";".join(sorted({mm[0] for mm in s.members})),
|
||||
"array_equivalent_alleles_2": ";".join(sorted({mm[-1] for mm in s.members})),
|
||||
"no_call_variants": ";".join(m.variant_ids[k][0] for k in no_calls),
|
||||
"alleles_impacted_by_no_call": "", "background_alleles": "", "variant_change": "",
|
||||
"score": f"{s.score:.4f}", "raw_score": f"{s.raw_score:.4f}",
|
||||
"copy_number_solution": f"{region}={gcn}" if region and gcn is not None else "",
|
||||
"flags": ";".join(flags + (["feasible"] if s in feasible else []))}) # fmt: skip
|
||||
genes_out.append({"gene": gene, "genotype": genotype, "feasible_solutions": [s.genotype for s in feasible],
|
||||
"candidates": [_sol_json(s) for s in sols], "copy_number": gcn,
|
||||
"flags": flags}) # fmt: skip
|
||||
gene_json, g_rows, g_sup, g_unres, g_ncov = call_one_gene(sid, sample_name, m, recs, masked, cn, o)
|
||||
if gene_json is None:
|
||||
continue
|
||||
genes_out.append(gene_json)
|
||||
rows += g_rows
|
||||
sup_rows += g_sup
|
||||
unres_rows += g_unres
|
||||
for x in g_ncov:
|
||||
not_cov_rows[(gene, str(x["hgvs"]))] = x
|
||||
calls_json.append({"sample_id": sid, "genes": genes_out})
|
||||
cols = ["sample_id", "sample_name", "solution_id", "gene", "call_type", "solution", "array_equivalent_alleles_1",
|
||||
"array_equivalent_alleles_2", "no_call_variants", "alleles_impacted_by_no_call", "background_alleles",
|
||||
"variant_change", "score", "raw_score", "copy_number_solution", "flags"] # fmt: skip
|
||||
o.out.mkdir(parents=True, exist_ok=True)
|
||||
write_tsv(o.out / "diplotype_summary.tsv", cols, rows)
|
||||
head = tsv_header(o.db, db_meta(o.db), "none")[:-1] # PGX-026 metadata (no guideline before phenotyping)
|
||||
write_meta_tsv(o.out / "diplotype_summary.tsv", head, cols, rows)
|
||||
write_meta_tsv(o.out / "supporting_variants.tsv", head,
|
||||
["sample_id", "solution_id", "gene", "allele", "hgvs", "rsid", "chrom", "pos", "ref", "alts",
|
||||
"genotype", "variant_copy_number", "gs", "baf", "probe_ids", "low_gs"], sup_rows) # fmt: skip
|
||||
write_meta_tsv(o.out / "unresolved_variants.tsv", head,
|
||||
["sample_id", "gene", "solution_id", "allele", "hgvs", "rsid", "reason", "probe_ids"],
|
||||
unres_rows) # fmt: skip
|
||||
write_meta_tsv(o.out / "variants_not_covered.tsv", head, ["gene", "hgvs", "rsid", "alleles_impacted"],
|
||||
[not_cov_rows[k] for k in sorted(not_cov_rows)]) # fmt: skip
|
||||
(o.out / "pgx_calls.json").write_text(json.dumps({"software": f"SimurgArray {__version__}", "db": o.db.name,
|
||||
"samples": calls_json}, indent=1)) # fmt: skip
|
||||
rep.info(f"star alleles: {len(calls_json)} sample(s), {len(models)} gene(s)")
|
||||
return ExitCode.OK
|
||||
|
||||
|
||||
def call_one_gene(
|
||||
sid: str, sample_name: str, m: GeneModel, recs: dict[tuple[str, int], list[dict[str, Any]]], masked: set[str],
|
||||
cn: dict[str, int | None], o: StarOptions,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]],
|
||||
list[dict[str, Any]]]: # fmt: skip
|
||||
"""One gene of one sample: call, details, and the rows of the four TSVs (PGX-012 … PGX-031)."""
|
||||
gene = m.gene
|
||||
obs, ploidy = observations(m, recs, masked)
|
||||
vrec = variant_records(m, recs)
|
||||
region = CN_REGION.get(gene)
|
||||
gcn = cn.get(region) if region else None
|
||||
sols, feasible = call_gene(m, obs, cn=gcn if region else None, ploidy=ploidy,
|
||||
max_candidates=o.max_candidates, feasible_ratio=o.feasible_ratio) # fmt: skip
|
||||
if not sols:
|
||||
return None, [], [], [], []
|
||||
top = sols[0]
|
||||
details = [solution_detail(m, obs, vrec, s_, top, o.low_gs, MAX_EDIT_DISTANCE) for s_ in sols]
|
||||
flags = []
|
||||
if any(sv["low_gs"] for al in details[0]["alleles"] for sv in al["supporting_variants"]):
|
||||
flags.append("review_alternatives") # PGX-016
|
||||
v_nc = [k for k in range(len(m.variants)) if obs.state[k] in (NO_CALL, MASKED)]
|
||||
v_ncov = [k for k in range(len(m.variants)) if obs.state[k] == NOT_COVERED]
|
||||
|
||||
def impacted(k: int) -> list[str]:
|
||||
return sorted((m.haplotypes[h] for h in np.flatnonzero(m.carries[:, k] == 1).tolist()), key=_natural)
|
||||
|
||||
def probes(k: int) -> list[str]:
|
||||
rec = vrec[k]
|
||||
return str(rec["id"]).split(",") if rec else []
|
||||
|
||||
exp_any = np.zeros(len(m.variants), dtype=np.int16)
|
||||
for i in top.copies:
|
||||
exp_any += m.carries[i] != 0
|
||||
unexplained = [m.variant_ids[k][0] for k in range(len(m.variants))
|
||||
if obs.state[k] == CALLED and obs.dosage[k] > 0 and exp_any[k] == 0] # fmt: skip
|
||||
if unexplained:
|
||||
flags.append("unexplained_variants") # PGX-031
|
||||
no_call_list = [{"hgvs": m.variant_ids[k][0], "rsid": m.variant_ids[k][1], "probe_ids": probes(k),
|
||||
"alleles_impacted": impacted(k)} for k in v_nc] # fmt: skip
|
||||
not_cov = [{"hgvs": m.variant_ids[k][0], "rsid": m.variant_ids[k][1], "alleles_impacted": impacted(k)}
|
||||
for k in v_ncov] # fmt: skip
|
||||
covered = obs.state != NOT_COVERED
|
||||
defining = m.carries == 1
|
||||
tested = sorted((m.haplotypes[h] for h in range(len(m.haplotypes))
|
||||
if not defining[h].any() or (defining[h] & covered).any()), key=_natural) # fmt: skip
|
||||
imp_nc = sorted({a for k in v_nc for a in impacted(k)}, key=_natural)
|
||||
rows, sup_rows, unres_rows = [], [], []
|
||||
for rank, (s_, d) in enumerate(zip(sols, details, strict=True), start=1):
|
||||
sflags = flags + d["flags"] + (["feasible"] if s_ in feasible else [])
|
||||
rows.append({"sample_id": sid, "sample_name": sample_name, "solution_id": rank, "gene": gene,
|
||||
"call_type": "star_allele", "solution": s_.genotype,
|
||||
"array_equivalent_alleles_1": ";".join(d["alleles"][0]["array_equivalent_alleles"]),
|
||||
"array_equivalent_alleles_2": ";".join(d["alleles"][-1]["array_equivalent_alleles"]),
|
||||
"no_call_variants": ";".join(str(x["hgvs"]) for x in no_call_list),
|
||||
"alleles_impacted_by_no_call": ";".join(imp_nc),
|
||||
"background_alleles": ";".join(al["background_allele"] for al in d["alleles"]
|
||||
if al["background_allele"]),
|
||||
"variant_change": ";".join(d["variant_change"] or []),
|
||||
"score": f"{s_.score:.4f}", "raw_score": f"{s_.raw_score:.4f}",
|
||||
"copy_number_solution": f"{region}={gcn}" if region and gcn is not None else "",
|
||||
"flags": ";".join(sflags)}) # fmt: skip
|
||||
for al in d["alleles"]:
|
||||
for sv in al["supporting_variants"]:
|
||||
sup_rows.append({"sample_id": sid, "solution_id": rank, "gene": gene, "allele": al["allele"],
|
||||
"hgvs": sv["hgvs"], "rsid": sv["rsid"], "chrom": sv["chrom"], "pos": sv["pos"],
|
||||
"ref": sv["ref"], "alts": ";".join(sv["alts"]), "genotype": sv["gt"],
|
||||
"variant_copy_number": sv["variant_copy_number"], "gs": sv["gs"], "baf": sv["baf"],
|
||||
"probe_ids": ";".join(sv["probe_ids"]),
|
||||
"low_gs": str(sv["low_gs"]).lower()}) # fmt: skip
|
||||
for uv in al["unresolved_variant_sites"]:
|
||||
unres_rows.append({"sample_id": sid, "gene": gene, "solution_id": rank, "allele": al["allele"],
|
||||
"hgvs": uv["hgvs"], "rsid": uv["rsid"], "reason": uv["reason"],
|
||||
"probe_ids": ";".join(uv["probe_ids"])}) # fmt: skip
|
||||
ncov_rows = [{"gene": gene, "hgvs": m.variant_ids[k][0], "rsid": m.variant_ids[k][1],
|
||||
"alleles_impacted": ";".join(impacted(k))} for k in v_ncov] # fmt: skip
|
||||
cands = []
|
||||
for s_, d in zip(sols, details, strict=True):
|
||||
cj = _sol_json(s_)
|
||||
cj.update({"alleles": d["alleles"], "variant_change": d["variant_change"], "flags": d["flags"],
|
||||
"variant_no_calls": no_call_list, "alleles_impacted_by_no_calls": imp_nc}) # fmt: skip
|
||||
cands.append(cj)
|
||||
gene_json = {"gene": gene, "call_type": "star_allele", "genotype": ";".join(s_.genotype for s_ in feasible),
|
||||
"feasible_solutions": [s_.genotype for s_ in feasible], "candidates": cands, "copy_number": gcn,
|
||||
"flags": flags, "variant_no_calls": no_call_list, "variants_not_covered": not_cov,
|
||||
"unexplained_variants": unexplained, "alleles_tested": tested} # fmt: skip
|
||||
return gene_json, rows, sup_rows, unres_rows, ncov_rows
|
||||
|
||||
|
||||
def _sol_json(s: Solution) -> dict[str, Any]:
|
||||
return {"genotype": s.genotype, "score": round(s.score, 4), "raw_score": round(s.raw_score, 4),
|
||||
"equivalents": ["/".join(m) for m in s.members][:20], "copy_number": s.copy_number} # fmt: skip
|
||||
|
||||
|
||||
def _natural(a: str) -> tuple[Any, ...]:
|
||||
import re
|
||||
|
||||
return tuple(int(x) if x.isdigit() else x for x in re.split(r"(\d+)", a))
|
||||
Reference in new issue
Block a user