PGx: CYP2D6 structures from sub-region CN — *68+*4, *36+*10, *13 (PGX-013/018)

Sub-region CNs (p5, intron 2, exon 9) resolve full copies and *36/*68/*13 units; candidates carry the
tandem names, background alleles, and CPIC activity sums. GeT-RM: CYP2D6 13/15 top (NA12878 *3/*68+*4 and
NA11832 *1/*68+*4 exact), 15/15 within feasible; all genes 132/144.
This commit is contained in:
kemal committed 2026-09-29 16:29:54 +01:00
1 parent e75eea10aa
commit c1de527d75
7 files changed
+129 -31

No files matched your search

+9 -6
View File
@@ -78,12 +78,15 @@ def phenotype(t: GuidelineTable, haplotypes: tuple[str, ...]) -> PhenotypeResult
deletion = {"*5", "*0"}
if t.activity_based:
vals: list[float] = []
for h in haplotypes:
core, n = _split(h)
a = 0.0 if core in deletion else t.activity.get(core)
if a is None:
return PhenotypeResult("Indeterminate", None, ["phenotype_not_in_guideline"])
vals.append(a * n)
for h in haplotypes: # a tandem "*36+*10" or "*68+*4" sums its units (CPIC activity-score rule)
hap_act = 0.0
for unit in h.split("+"):
core, n = _split(unit)
a = 0.0 if core in deletion else t.activity.get(core)
if a is None:
return PhenotypeResult("Indeterminate", None, ["phenotype_not_in_guideline"])
hap_act += a * n
vals.append(hap_act)
counts: dict[str, int] = {}
for v in vals:
s = f"{v:.1f}" if abs(v * 10 - round(v * 10)) < 1e-9 else f"{v:g}"
+70 -3
View File
@@ -115,14 +115,81 @@ def candidates(m: GeneModel, cn: int | None) -> list[tuple[tuple[int, ...], tupl
return out
@dataclass(frozen=True)
class Cyp2d6Structure:
"""CYP2D6 copies from the sub-region CNs (PGX-013) [OURS; DOC: PharmVar CYP2D6 structural variation]:
``full`` complete CYP2D6 copies, ``u36`` *36-type units (CYP2D7 exon 9), ``u68`` *68-type units (CYP2D6 exon 1
only), ``u13`` *13-type units (CYP2D7 5′ part). CN(p5) = full + u36 + u68, CN(intron 2) = full + u36 + u13,
CN(exon 9) = full + u13; with u68 · u13 = 0 this has one solution."""
full: int
u36: int
u68: int
u13: int
def cyp2d6_structure(p5: int | None, intron2: int | None, exon9: int | None) -> Cyp2d6Structure | None:
if p5 is None:
return None
ni = p5 if intron2 is None else intron2
n9 = ni if exon9 is None else exon9
u36 = max(0, ni - n9)
x = p5 - u36 - n9
if x >= 0:
full, u68, u13 = n9, x, 0
else:
full, u68, u13 = p5 - u36, 0, -x
if full < 0:
return Cyp2d6Structure(max(0, p5), 0, 0, 0)
return Cyp2d6Structure(full, u36, u68, u13)
def structure_candidates(m: GeneModel, st: Cyp2d6Structure) -> list[tuple[tuple[int, ...], tuple[str, ...]]]:
"""Candidates for a CYP2D6 structure: SNV copies = full + u36 (a *36 unit carries the *10 sequence up to exon 8);
*36 units attach to a *10 copy (``*36+*10``), *68 units to *4 when present (``*68+*4``), *13 units are separate
haplotypes."""
snv_copies = st.full + st.u36
base = candidates(m, snv_copies)
i10 = m.haplotypes.index("*10") if "*10" in m.haplotypes else None
out = []
for ix, names in base:
names_l = list(names)
if st.u36:
if i10 is None or ix.count(i10) < st.u36 + 1: # the *36 unit(s) plus the *10 they precede
continue
k = next((n for n, x in enumerate(names_l) if x.startswith("*10")), None)
if k is None:
continue
unit = "*36" if st.u36 == 1 else f"*36x{st.u36}"
names_l[k] = f"{unit}+*10"
if st.u68:
unit = "*68" if st.u68 == 1 else f"*68x{st.u68}"
k = next((n for n, x in enumerate(names_l) if x.split("+")[-1].split("x")[0] == "*4"), 0)
names_l[k] = f"{unit}+{names_l[k]}"
if st.u13:
if len(names_l) == 2 and names_l[1] == (m.deletion_allele or "*0"):
names_l[1] = "*13" if st.u13 == 1 else f"*13x{st.u13}"
else:
names_l[0] = f"*13+{names_l[0]}"
out.append((ix, tuple(names_l)))
return out
def call_gene(
m: GeneModel, obs: Observations, cn: int | None = None, baf_sigma: float = 0.06, ploidy: int = 2,
max_candidates: int = 10, feasible_ratio: float = 0.5,
max_candidates: int = 10, feasible_ratio: float = 0.5, structure: Cyp2d6Structure | None = None,
) -> tuple[list[Solution], list[Solution]]: # fmt: skip
"""All scored solutions (ranked) and the feasible ones (PGX-014/015)."""
called = obs.state == CALLED
haploid = [((i,), (m.haplotypes[i],)) for i in range(len(m.haplotypes))]
cands = haploid if ploidy == 1 else candidates(m, cn)
haploid: list[tuple[tuple[int, ...], tuple[str, ...]]] = [((i,), (m.haplotypes[i],)) for i in
range(len(m.haplotypes))] # fmt: skip
if ploidy == 1:
cands = haploid
elif structure is not None and (structure.u36 or structure.u68 or structure.u13):
cands = structure_candidates(m, structure)
cn = structure.full + structure.u36
else:
cands = candidates(m, cn)
if not cands:
return [], []
carries = m.carries.astype(np.int16)
+19 -4
View File
@@ -23,11 +23,22 @@ 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
from simurg.pgx.star import (
CALLED,
MASKED,
NO_CALL,
NOT_COVERED,
GeneModel,
Observations,
Solution,
call_gene,
cyp2d6_structure,
)
MAX_EDIT_DISTANCE = 3 # PGX-019
CN_REGION = {"CYP2D6": "CYP2D6.p5"} # PGX-013: total gene copies from the 5′ region
CN_SOLUTION_REGIONS = {"CYP2D6": ("CYP2D6.p5", "CYP2D6.intron.2", "CYP2D6.exon.9")} # PGX-027 copy_number_solution
CN_ONLY_GENES = {"GSTM1": "GSTM1", "GSTT1": "GSTT1", "UGT2B17": "UGT2B17"} # PGX-006: *0 from whole-gene CN
ALL_BASES = ("star_allele", "haplotype_named", "variant_based", "tagging_variant")
TAG_CAVEAT = "tag reliability varies by population"
@@ -350,8 +361,11 @@ def call_one_gene(
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
structure = None
if gene == "CYP2D6" and gcn is not None:
structure = cyp2d6_structure(gcn, cn.get("CYP2D6.intron.2"), cn.get("CYP2D6.exon.9"))
sols, feasible = call_gene(m, obs, cn=gcn if region else None, ploidy=ploidy, max_candidates=o.max_candidates,
feasible_ratio=o.feasible_ratio, structure=structure) # fmt: skip
if not sols:
return None, [], [], [], []
top = sols[0]
@@ -398,7 +412,8 @@ def call_one_gene(
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 "",
"copy_number_solution": ";".join(f"{r}={cn.get(r)}" for r in CN_SOLUTION_REGIONS.get(gene, ())
if cn.get(r) is not None),
"flags": ";".join(sflags)}) # fmt: skip
for al in d["alleles"]:
for sv in al["supporting_variants"]: