PGx: full call detail (PGX-016..020, 027/028, 031), pgx coverage (PGX-009/035), full HGVS
ci / test (3.12) (push) Failing after 9s
ci / test (3.13) (push) Failing after 11s

- 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:
kemalandClaude Opus 5.5 committed 2026-09-29 16:16:39 +01:00
1 parent 0215af9c9c
commit 4dafce3f18
10 files changed
+352 -40

No files matched your search

+187 -27
View File
@@ -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))