Files
SimurgArray/src/simurg/pgx/db.py
T
kemal e1cfb3dfd0 PGx: ClinPGx variant-based genes (PGX-008) and --guideline dpwg (PGX-025)
- fetch_sources.sh downloads ClinPGx clinicalVariants (CC BY-SA 4.0); pgx db build adds genotype-only
  variant-based genes (levels 1A-2B, level 3 for the parity list), rsIDs resolved from the manifest's probes:
  70 genes in the DB.
- pgx phenotype --guideline dpwg: CPIC mappings for CPIC genes (as ClinPGx curates DPWG), no phenotype for
  DPWG-only genes (flags dpwg_via_cpic_mapping / dpwg_mapping_not_available). K-029 updated.
2026-09-29 17:01:36 +01:00

290 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PGx allele definitions from public sources → normalised VCF-style variants — spec PGX-001 … PGX-005.
Pure functions: the reference sequence comes in as a ``fetch(contig, start0, end0)`` callable.
CPIC tables (CC0, https://api.cpicpgx.org) [DB: CPIC allele definition, allele function, diplotype→phenotype]:
``sequence_location`` gives each defining position as HGVS on the chromosome (``g.``/``m.``); ``allele_location_value``
gives each allele's value there. Values are literal sequences (``A``, ``AA`` for a dup, ``TA(7)`` for a repeat) or
``del…``/``ins…`` forms; the reference allele definition carries the reference value. Every value becomes a
replacement of a reference window, then a left-normalised, anchored VCF (POS, REF, ALT) — the representation the
SNV VCFs use (IN-046), so definitions and array records compare directly.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass, field
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")
# 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)
class Window:
"""Reference interval [start, end] (1-based, inclusive) replaced by allele values; ``insertion`` windows are the
single anchor base S of ``g.S_EinsX`` (values are anchor + inserted sequence)."""
start: int
end: int
kind: str # snv | del | ins | dup | repeat | delins
unit: str = "" # repeat unit
def parse_location(hgvs: str) -> Window | None:
"""First HGVS expression of a CPIC ``chromosomelocation`` (several are ``;``-joined for one site)."""
first = hgvs.split(";")[0].strip()
m = HGVS_RE.match(first)
if not m:
return None
s = int(m.group(2))
e = int(m.group(3)) if m.group(3) else s
rest = m.group(4)
if re.fullmatch(r"[ACGT]>[ACGT]", rest):
return Window(s, s, "snv")
if rest.startswith("delins"):
return Window(s, e, "delins")
if rest.startswith("del"):
return Window(s, e, "del")
if rest.startswith("ins"):
return Window(s, s, "ins")
if rest.startswith("dup"):
return Window(s, e, "dup")
rm = re.fullmatch(r"([ACGT]+)\[(\d+)\]", rest)
if rm:
return Window(s, s, "repeat", rm.group(1))
return None
def value_sequence(value: str, w: Window, fetch: Fetch, contig: str) -> tuple[int, int, str] | None:
"""(start, end, sequence) that the value puts in place of the reference window; None = unusable/ambiguous."""
v = value.strip()
if " or " in v or ";" in v or not v:
return None
if w.kind == "repeat":
m = REPEAT_RE.match(v)
unit = w.unit
n_ref = 0
while fetch(contig, w.start - 1 + n_ref * len(unit), w.start - 1 + (n_ref + 1) * len(unit)) == unit:
n_ref += 1
end = w.start - 1 + n_ref * len(unit)
if m:
return w.start, end, m.group(1) * int(m.group(2))
if re.fullmatch(r"[ACGT]+", v):
return w.start, end, v
return None
if w.kind == "ins":
anchor = fetch(contig, w.start - 1, w.start)
if v == "del":
return w.start, w.start, anchor
if v.startswith("ins"):
return w.start, w.start, anchor + v[3:]
return None
if w.kind == "delins":
if v.startswith("del"):
return w.start, w.end, fetch(contig, w.start - 1, w.end)
if v.startswith("ins"):
return w.start, w.end, v[3:]
return None
if v.startswith("del"):
return w.start, w.end, ""
if re.fullmatch(r"[ACGT]+", v):
return w.start, w.end, v
return None
def normalize(fetch: Fetch, contig: str, start: int, end: int, alt: str) -> tuple[int, str, str]:
"""Replacement of reference [start, end] (1-based; end = start − 1 for a pure insertion before start) by ``alt``
→ left-normalised, anchored (POS, REF, ALT) [LIT: Tan et al. 2015 Bioinformatics 31:2202 (vt normalize)]."""
ref = fetch(contig, start - 1, end)
pos = start
# trim the common suffix, then the common prefix
while ref and alt and ref[-1] == alt[-1]:
ref, alt = ref[:-1], alt[:-1]
while len(ref) > 0 and len(alt) > 0 and ref[0] == alt[0]:
ref, alt = ref[1:], alt[1:]
pos += 1
if ref and alt and len(ref) == len(alt):
return pos, ref, alt # SNV / MNV
# left shift the indel while the base before it equals its last base
while pos > 1 and (ref or alt):
prev = fetch(contig, pos - 2, pos - 1)
body = ref or alt
if prev != body[-1] or (ref and alt):
break
if ref:
ref = prev + ref[:-1]
else:
alt = prev + alt[:-1]
pos -= 1
anchor = fetch(contig, pos - 2, pos - 1)
return pos - 1, anchor + ref, anchor + alt
@dataclass
class VariantSite:
location_id: int
gene: str
chrom: str # contig name as in the FASTA
hgvs: str
rsid: str | None
ref_value: str | None
alleles: dict[str, tuple[int, str, str]] = field(default_factory=dict) # value → normalised (pos, ref, alt)
@dataclass
class AlleleDef:
gene: str
name: str
function: str | None
activity: str | None
reference: bool
structural: bool
variants: list[tuple[int, str]] = field(default_factory=list) # (location_id, value) where ≠ reference
frequencies: dict[str, float] = field(default_factory=dict)
wildcards: list[int] = field(default_factory=list) # location ids with an IUPAC value (either base)
@property
def undefined(self) -> bool:
"""A non-reference allele without any defining variant cannot be called (e.g. CPIC NUDT15 *2)."""
return not self.reference and not self.variants
def cpic_definitions(
t: dict[str, list[dict[str, Any]]], fetch: Fetch, contig_of: Callable[[str], str | None]
) -> tuple[dict[str, list[AlleleDef]], dict[int, VariantSite], list[str]]:
"""CPIC tables → (gene → allele definitions, location id → site, problems)."""
problems: list[str] = []
gene_chr = {g["symbol"]: g.get("chr") for g in t["gene"]}
locs = {loc["id"]: loc for loc in t["sequence_location"]}
values: dict[int, list[tuple[int, str]]] = {}
for v in t["allele_location_value"]:
values.setdefault(v["alleledefinitionid"], []).append((v["locationid"], v["variantallele"]))
func = {(a["genesymbol"], a["name"]): a for a in t["allele"]}
defs: dict[str, list[AlleleDef]] = {}
sites: dict[int, VariantSite] = {}
ref_value: dict[int, str] = {}
for d in sorted(t["allele_definition"], key=lambda d: (d["genesymbol"], d["name"])):
if d.get("matchesreferencesequence"):
for lid, val in values.get(d["id"], []):
ref_value[lid] = val
for d in sorted(t["allele_definition"], key=lambda d: (d["genesymbol"], d["name"])):
g = d["genesymbol"]
a = func.get((g, d["name"]), {})
freq = {k: float(v) for k, v in (a.get("frequency") or {}).items() if isinstance(v, int | float)}
ref_def, sv = bool(d.get("matchesreferencesequence")), bool(d.get("structuralvariation"))
ad = AlleleDef(g, d["name"], a.get("clinicalfunctionalstatus"), a.get("activityvalue"), ref_def, sv, [], freq)
for lid, val in sorted(values.get(d["id"], [])):
loc = locs.get(lid)
if loc is None:
continue
if lid not in sites:
hg = loc.get("chromosomelocation") or ""
chrom = "chrM" if hg.startswith("m.") else (gene_chr.get(g) or "")
contig = contig_of(chrom) if chrom else None
if contig is None:
problems.append(f"{g} {loc['name']}: no contig for {chrom!r}")
continue
sites[lid] = VariantSite(lid, g, contig, hg, loc.get("dbsnpid"), ref_value.get(lid))
site = sites[lid]
if len(val) == 1 and val in IUPAC_WILDCARD:
ad.wildcards.append(lid)
elif val != site.ref_value:
ad.variants.append((lid, val))
defs.setdefault(g, []).append(ad)
for lid, site in sites.items():
w = parse_location(site.hgvs)
if w is None:
problems.append(f"{site.gene} {site.hgvs}: unparsed location")
continue
vals = {val for d in defs.get(site.gene, []) for (l2, val) in d.variants if l2 == lid}
for val in sorted(vals):
seq = value_sequence(val, w, fetch, site.chrom)
if seq is None:
problems.append(f"{site.gene} {site.hgvs}: value {val!r} not representable")
continue
s, e, alt = seq
if w.kind == "ins":
s, e, alt = s + 1, s, alt[1:]
site.alleles[val] = normalize(fetch, site.chrom, s, e, alt)
return defs, sites, problems
def result_basis(gene: str, names: list[str]) -> str:
"""PGX-002 ``genes.result_basis``."""
if gene in ("HLA-A", "HLA-B"):
return "tagging_variant"
if gene in ("G6PD", "APOE"):
return "haplotype_named"
if names and all(n.startswith("*") for n in names):
return "star_allele"
return "variant_based"
def pharmvar_core_alleles(gene: str, vcfs: dict[str, str]) -> dict[str, list[tuple[str, int, str, str, str | None]]]:
"""PharmVar per-allele GRCh38 VCFs (file name → text) → core alleles (``*N``; sub-alleles ``*N.001`` skipped) and
their variants (chrom, pos, ref, alt, rsid) as written by PharmVar [DB: PharmVar haplotype VCFs]."""
out: dict[str, list[tuple[str, int, str, str, str | None]]] = {}
pat = re.compile(rf"^{re.escape(gene)}_(\d+)\.vcf$")
for name, text in vcfs.items():
m = pat.match(name.rsplit("/", 1)[-1])
if not m:
continue
recs = []
for ln in text.splitlines():
if not ln or ln.startswith("#"):
continue
f = ln.split("\t")
rsid = f[2] if f[2].startswith("rs") else None
for alt in f[4].split(","):
recs.append((f[0], int(f[1]), f[3].upper(), alt.upper(), rsid))
out[f"*{m.group(1)}"] = recs
return out
CLINPGX_LEVELS = ("1A", "1B", "2A", "2B")
# PGX-008 parity list of variant-based genes: level-3 annotations are kept for these too
PARITY_VARIANT_GENES = {"DPYD", "RYR1", "OPRM1", "CFTR", "DRD2", "COMT", "OPRD1", "CACNA1C", "HTR2A", "OPRK1",
"MT-RNR1", "TNF", "BDNF", "IL6", "HTR2C", "GRIN2B", "MTHFR", "ITGB3", "FKBP5", "ADH1B",
"ANKK1", "HMGCR", "ATM", "KIF6", "F2", "CACNA1S", "LPA", "F13A1", "GRIK1", "GRIK4", "YEATS4",
"ABCG2", "F5", "ALDH2", "ANK3", "VKORC1"} # fmt: skip
def clinpgx_variant_genes(tsv: str, exclude: set[str], levels: tuple[str, ...] = CLINPGX_LEVELS) -> dict[str, set[str]]:
"""ClinPGx ``clinicalVariants.tsv`` → gene → rsIDs with a clinical annotation at the given evidence levels, for
single-gene rows of genes that no allele-definition source covers (level 3 as well for the PGX-008 parity genes)
[DB: ClinPGx clinical annotations, CC BY-SA 4.0].
Positions and alleles are resolved later from the manifest's probes (genotype-only variant-based genes)."""
out: dict[str, set[str]] = {}
lines = tsv.splitlines()
head = lines[0].split("\t")
iv, ig, il = head.index("variant"), head.index("gene"), head.index("level of evidence")
for ln in lines[1:]:
f = ln.split("\t")
if len(f) <= max(iv, ig, il):
continue
gene, var, lev = f[ig].strip(), f[iv].strip(), f[il].strip()
ok_level = lev in levels or (lev == "3" and gene in PARITY_VARIANT_GENES)
if "," in gene or not gene or gene in exclude or not ok_level or not re.fullmatch(r"rs\d+", var):
continue
out.setdefault(gene, set()).add(var)
return out