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

+1
View File
@@ -106,6 +106,7 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `W_PGX_BATCH_BELOW_RECOMMENDED` | warning | Fewer passing samples than recommended | PCN-004 |
| `W_PGX_BATCH_COMPOSITION` | warning | Batch spans several lots/batches or > 14 days of scans | PCN-006 |
| `W_PGX_NO_ALT_CONTIG` | warning | FASTA lacks a contig carrying a PGx region; REF written as N | IN-062 |
| `W_PGX_NO_PROBE_MAP` | warning | The PGx DB has no probe map for this manifest | PGX-035 |
| `W_PGX_REGION_FEW_PROBES` | warning | PGx CN region has fewer than 10 unmasked probes | PCN-008 |
| `W_QC_LABEL_SANITIZED` | warning | Dataset label was sanitized | QC-042 |
| `W_QC_NO_BAF` | warning | GTC has no B-allele frequencies; contamination not estimated | QC-064 |
+10 -2
View File
@@ -61,6 +61,14 @@ so the per-call error is 1e-3 (GS ≥ 0.15) and errors are handled by the probe
* CPIC now defines the GRCh38 reference as its own allele where it differs from *1 (CYP2C19 *38): a sample
heterozygous at the *1-defining rs3758581 fits *1/*38 best (raw score 0.98), but *38 has no CPIC frequency, so the
prior keeps *1/*1 on top — which is what legacy GeT-RM reports.
* `simurg pgx coverage` (PGX-009/035) on GSA v4 PGx with the mask: 438 alleles callable, 68 array-equivalent to
another allele, 88 not covered, 4 masked. Against the reference's known limitations: CYP4F2 *2 not covered (reported
as *1) and *4 ≡ *3 — as the reference; UGT1A1 *28 callable (its C>CAT probe matches 1000G) and *37 masked (the
C>CATAT probe fails); CYP2D6 *40/*58 and NUDT15 *6/*9 are separable here (CPIC core definitions differ from the
reference's).
* Detail outputs (PGX-016 … PGX-020, PGX-027/028, PGX-031): `supporting_variants.tsv`, `unresolved_variants.tsv`,
`variants_not_covered.tsv`, per-allele supporting/unresolved sites for **both** alleles of every candidate,
`variant_change` vs the top solution (`distant_alternative` above 3 changes), `unexplained_variants`, full HGVS on
GRCh38 RefSeq accessions.
* Not yet: named-haplotype (G6PD), variant-based and tagging genes; CYP2D6 hybrids (*13, *36, *68); PharmVar-only
genes (CYP2A6, CYP3A4, CYP1A2, …); DPWG tables; the full per-allele supporting/unresolved-variant detail of
PGX-027/028 (`supporting_variants.tsv`, `unresolved_variants.tsv`, `variants_not_covered.tsv`).
genes (CYP2A6, CYP3A4, CYP1A2, …); DPWG tables.
+16
View File
@@ -910,6 +910,22 @@ def pgx_annotate_cmd(
_pgx_phenotype(calls, db, out, guideline, force, log_json, debug, quiet, "pgx annotate")
@pgx_app.command("coverage")
def pgx_coverage_cmd(
bpm: Annotated[Path, typer.Option("--bpm", help="BPM manifest (required).")],
db: Annotated[Path, typer.Option("--db", help="PGx DB built with this manifest (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder.")] = Path("."),
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Which alleles this array can call: pgx_coverage.tsv (PGX-009, PGX-035)."""
from simurg.pipeline.pgx_db import run_coverage
rep = _reporter("pgx coverage", out, log_json, debug, quiet)
run_guarded(rep, lambda: run_coverage(bpm, db, out, rep))
@pgx_cn_app.command("train")
def pgx_cn_train_cmd(
gtcs: Annotated[Path, typer.Option("--gtcs", help="Folder searched recursively for training GTCs (required).")],
+1
View File
@@ -175,6 +175,7 @@ CATALOGUE: dict[str, MessageDef] = dict(
_m("E_PGX_VCF_UNINDEXED", "error", "SNV VCFs for PGx must be bgzipped and indexed", "PGX-011"),
_m("E_PGX_VCF_UNSUITABLE", "error", "SNV VCF unsuitable for PGx (squashing/locus filters/foreign)", "PGX-011"),
_m("E_PGX_GUIDELINE", "error", "Guideline tables not present in the PGx DB", "PGX-025"),
_m("W_PGX_NO_PROBE_MAP", "warning", "The PGx DB has no probe map for this manifest", "PGX-035"),
_m("E_PGX_SOURCE", "error", "PGx source tables missing; run scripts/pgx/fetch_sources.sh", "PGX-001"),
# --- methylation control QC (MET-001 .. MET-018) -------------------------------------------------
_m("E_METH_NO_ASSAY", "error", "Probe section not found", "MET-002"),
+17 -1
View File
@@ -19,7 +19,23 @@ from typing import Any
Fetch = Callable[[str, int, int], str]
HGVS_RE = re.compile(r"^([gm])\.(\d+)(?:_(\d+))?(.*)$")
REPEAT_RE = re.compile(r"^([ACGT]+)\((\d+)\)$")
IUPAC_WILDCARD = set("RYSWKMBDHVN") # CPIC core-allele definitions: the position does not constrain the allele
IUPAC_WILDCARD = set("RYSWKMBDHVN")
# GRCh38 RefSeq chromosome accessions, for full HGVS (PGX-028 "NC_000022.11:g.42126611C>G")
REFSEQ_GRCH38 = {"1": "NC_000001.11", "2": "NC_000002.12", "3": "NC_000003.12", "4": "NC_000004.12",
"5": "NC_000005.10", "6": "NC_000006.12", "7": "NC_000007.14", "8": "NC_000008.11",
"9": "NC_000009.12", "10": "NC_000010.11", "11": "NC_000011.10", "12": "NC_000012.12",
"13": "NC_000013.11", "14": "NC_000014.9", "15": "NC_000015.10", "16": "NC_000016.10",
"17": "NC_000017.11", "18": "NC_000018.10", "19": "NC_000019.10", "20": "NC_000020.11",
"21": "NC_000021.9", "22": "NC_000022.11", "X": "NC_000023.11", "Y": "NC_000024.10",
"M": "NC_012920.1", "MT": "NC_012920.1"} # fmt: skip
def full_hgvs(chrom: str, hgvs: str) -> str:
"""``g.…``/``m.…`` → ``NC_…:g.…`` on the GRCh38 chromosome accession (unchanged when unknown)."""
acc = REFSEQ_GRCH38.get(chrom.removeprefix("chr"))
return (
f"{acc}:{hgvs.split(';')[0].strip()}" if acc and not hgvs.startswith("NC_") else hgvs
) # CPIC core-allele definitions: the position does not constrain the allele
@dataclass(frozen=True)
+3 -2
View File
@@ -59,6 +59,7 @@ class Solution:
raw_score: float = 0.0
score: float = 0.0
copy_number: int = 2
copies: tuple[int, ...] = () # haplotype index of every gene copy of the representative (SNV model)
@property
def genotype(self) -> str:
@@ -157,8 +158,8 @@ def call_gene(
p *= 2
pri.append(p)
best = members[int(np.argmax(pri))]
sols.append(Solution(cands[best][1], float(ll[best]), float(sum(pri)),
[cands[k][1] for k in members], copy_number=cn if cn is not None else 2)) # fmt: skip
sols.append(Solution(cands[best][1], float(ll[best]), float(sum(pri)), [cands[k][1] for k in members],
copy_number=cn if cn is not None else 2, copies=cands[best][0])) # fmt: skip
lls = np.array([s.ll for s in sols])
lp = lls + np.log([s.prior for s in sols])
raw = np.exp(lls - lls.max())
+71 -3
View File
@@ -113,7 +113,7 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
"""``simurg pgx db build`` (CLI-035)."""
from simurg.formats.bpm import read_bpm
from simurg.formats.fasta import ContigMap, Genome
from simurg.pgx.db import cpic_definitions, result_basis
from simurg.pgx.db import cpic_definitions, full_hgvs, result_basis
cdir = _latest(o.sources, "cpic")
if cdir is None:
@@ -145,14 +145,14 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
continue
pos, ref, alt = s.alleles[val]
rows["allele_variants"].append(
(gene, d.name, "GRCh38", s.chrom, pos, ref, alt, s.hgvs, s.rsid, lid, val)
(gene, d.name, "GRCh38", s.chrom, pos, ref, alt, full_hgvs(s.chrom, s.hgvs), s.rsid, lid, val)
)
for lid in d.wildcards:
rows["allele_wildcards"].append((gene, d.name, lid))
for pop, f in sorted(d.frequencies.items()):
rows["frequencies"].append((gene, d.name, pop, f))
for lid, s in sorted(sites.items()):
rows["sites"].append((s.gene, lid, s.chrom, s.hgvs, s.rsid, s.ref_value))
rows["sites"].append((s.gene, lid, s.chrom, full_hgvs(s.chrom, s.hgvs), s.rsid, s.ref_value))
for val, (pos, ref, alt) in sorted(s.alleles.items()):
rows["site_values"].append((s.gene, lid, val, pos, ref, alt))
results = {r["id"]: r for r in t["gene_result"]}
@@ -221,3 +221,71 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
rep.info(f"PGx DB: {len(defs)} genes, {len(rows['alleles'])} alleles, {len(rows['allele_variants'])} variant "
f"rows, {len(rows['probe_map'])} probe links → {final} (content sha256 {sha})") # fmt: skip
return ExitCode.OK
# ------------------------------------------------------------------------------------------------ coverage
def coverage_rows(db: Path, manifest: str) -> list[dict[str, Any]]:
"""PGX-009 / PGX-035: per allele, callable | not_covered | masked | equivalent_to:<allele> on a manifest."""
import numpy as np
from simurg.pipeline.pgx_star import load_models
con = sqlite3.connect(db)
links: dict[tuple[str, int, str, str], list[str]] = {}
for probe, c, p, r, a in con.execute("SELECT probe_name, chrom, pos, ref, alt FROM probe_map WHERE manifest = ?",
(manifest,)): # fmt: skip
links.setdefault((c, p, r, a), []).append(probe)
masked = {p for (p,) in con.execute("SELECT probe_name FROM masks WHERE manifest = ?", (manifest,))}
basis = dict(con.execute("SELECT gene, result_basis FROM genes").fetchall())
kinds = {(g, a): (k, d) for g, a, k, d in con.execute("SELECT gene, allele, kind, defined FROM alleles")}
con.close()
out = []
for gene, m in sorted(load_models(db, "global").items()):
usable = np.array([bool(links.get(v)) and not all(pr in masked for pr in links[v]) for v in m.variants])
masked_v = np.array([bool(links.get(v)) and all(pr in masked for pr in links[v]) for v in m.variants])
sig: dict[bytes, list[int]] = {}
for h in range(len(m.haplotypes)):
sig.setdefault(np.where(usable, m.carries[h], -9).tobytes(), []).append(h)
rep = {}
for members in sig.values():
best = max(members, key=lambda h: (m.freq[h], -h))
for h in members:
rep[h] = best
for h, name in enumerate(m.haplotypes):
defining = m.carries[h] == 1
probes = sorted({pr for k in np.flatnonzero(defining).tolist() for pr in links.get(m.variants[k], [])})
if defining.any() and not (defining & usable).any():
status = "masked" if (defining & masked_v).any() else "not_covered"
elif rep[h] != h:
status = f"equivalent_to:{m.haplotypes[rep[h]]}"
else:
status = "callable"
out.append({"gene": gene, "allele": name, "allele_kind": kinds.get((gene, name), ("", 1))[0],
"result_basis": basis.get(gene, ""), "status": status,
"covering_probes": ";".join(probes)}) # fmt: skip
con = sqlite3.connect(db)
for gene, allele, kind in con.execute("SELECT gene, allele, kind FROM alleles WHERE defined = 0 ORDER BY 1, 2"):
out.append({"gene": gene, "allele": allele, "allele_kind": kind, "result_basis": basis.get(gene, ""),
"status": "not_covered", "covering_probes": ""}) # fmt: skip
con.close()
return out
def run_coverage(bpm: Path, db: Path, out: Path, rep: Reporter) -> int:
"""``simurg pgx coverage --bpm --db`` (PGX-035)."""
from simurg.formats.bpm import read_bpm
from simurg.pipeline.pgx_pheno import db_meta, tsv_header, write_meta_tsv
name = read_bpm(bpm).manifest_name
rows = coverage_rows(db, name)
if not any(r["covering_probes"] for r in rows):
rep.warn("W_PGX_NO_PROBE_MAP", f"the PGx DB has no probe map for {name}; rebuild it with --manifests")
out.mkdir(parents=True, exist_ok=True)
cols = ["gene", "allele", "allele_kind", "result_basis", "status", "covering_probes"]
write_meta_tsv(out / "pgx_coverage.tsv", tsv_header(db, db_meta(db), "none")[:-1], cols, rows)
counts: dict[str, int] = {}
for r in rows:
k = r["status"].split(":")[0]
counts[k] = counts.get(k, 0) + 1
rep.info(f"{name}: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
return ExitCode.OK
+20 -5
View File
@@ -98,15 +98,24 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
else:
res, per = gene_phenotype(t, feas)
flags = list(g.get("flags", [])) + (res.flags if res else [])
entry = {"gene": g["gene"], "call_type": "star_allele", "genotype": g["genotype"],
top = g["candidates"][0] if g["candidates"] else {}
top_alleles = top.get("alleles", [])
entry = {"gene": g["gene"], "call_type": g.get("call_type", "star_allele"), "genotype": g["genotype"],
"feasible_solutions": g["feasible_solutions"],
"activity_score": res.activity_score if res else None,
"phenotype": res.phenotype if res else None, "per_solution_phenotypes": per,
"score": g["candidates"][0]["score"] if g["candidates"] else None,
"raw_score": g["candidates"][0]["raw_score"] if g["candidates"] else None,
"score": top.get("score"), "raw_score": top.get("raw_score"),
"flags": sorted(set(flags)), "copy_number": g.get("copy_number"),
"candidate_solutions": [{"rank": i + 1, **c} for i, c in enumerate(g["candidates"])],
"alleles_tested": sorted(tested.get(g["gene"], []), key=_natural)} # fmt: skip
"array_equivalent_alleles": [{"allele": a["allele"], "equivalents": a["array_equivalent_alleles"]}
for a in top_alleles],
"supporting_variants": [v for a in top_alleles for v in a["supporting_variants"]],
"variant_no_calls": g.get("variant_no_calls", []),
"variants_not_covered": g.get("variants_not_covered", []),
"unexplained_variants": g.get("unexplained_variants", []),
"candidate_solutions": [{"rank": i + 1, **c, "phenotype": _pheno_of(tables.get(g["gene"]), c)}
for i, c in enumerate(g["candidates"])],
"alleles_tested": g.get("alleles_tested")
or sorted(tested.get(g["gene"], []), key=_natural)} # fmt: skip
genes.append(entry)
ann_rows.append({"sample_id": s["sample_id"], "sample_name": s.get("sample_name", s["sample_id"]),
"gene": g["gene"], "guideline": o.guideline, "genotype": g["genotype"],
@@ -126,6 +135,12 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
return ExitCode.OK
def _pheno_of(t: Any, cand: dict[str, Any]) -> str | None:
from simurg.pgx.phenotype import phenotype
return phenotype(t, tuple(cand["genotype"].split("/"))).phenotype if t is not None else None
def _natural(a: str) -> tuple[Any, ...]:
import re
+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))
+26
View File
@@ -121,3 +121,29 @@ def test_db_build_deterministic(tmp_path: Path) -> None: # IN-053, CLI-035
meta = dict(con.execute("select key, value from meta").fetchall())
assert meta["content_sha256"][:12] in outs[0].name and json.loads(meta["allele_sources"])[0]["name"] == "CPIC"
assert con.execute("select phenotype from phenotypes").fetchone() == ("Poor Metabolizer",)
def test_coverage_statuses(tmp_path: Path) -> None: # PGX-009 / PGX-035
from simurg.pipeline.pgx_db import coverage_rows
src = tmp_path / "sources" / "cpic-20260101"
src.mkdir(parents=True)
for k, v in _tables().items():
(src / f"{k}.json").write_text(json.dumps(v))
(src / "source.json").write_text(json.dumps({"name": "CPIC", "version": "2026-01-01", "retrieved": "20260101"}))
fa = tmp_path / "g.fa"
fa.write_text(">chr1\n" + SEQ + "\n")
out = tmp_path / "db"
r = CliRunner().invoke(app, ["pgx", "db", "build", "--sources", str(tmp_path / "sources"), "--fasta", str(fa),
"--out", str(out), "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
db = next(out.glob("pgx_db-1-*.sqlite"))
con = sqlite3.connect(db)
con.executemany("INSERT INTO probe_map VALUES (?, ?, ?, ?, ?, ?)",
[("M", "p_star2", "chr1", 5, "A", "G"), ("M", "p_star3", "chr1", 7, "GT", "G")]) # fmt: skip
con.execute("INSERT INTO masks VALUES ('M', 'p_star3', 'test')")
con.commit()
con.close()
st = {r["allele"]: r["status"] for r in coverage_rows(db, "M")}
assert st["*1"] == "callable" and st["*2"] == "callable"
assert st["*3"] == "masked" and st["*5"] == "not_covered"