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.
This commit is contained in:
kemal committed 2026-09-29 17:01:36 +01:00
1 parent 4d9efab2fc
commit cc89e9b334
8 files changed
+161 -10

No files matched your search

+29
View File
@@ -258,3 +258,32 @@ def pharmvar_core_alleles(gene: str, vcfs: dict[str, str]) -> dict[str, list[tup
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
+48 -3
View File
@@ -254,6 +254,47 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
hg = full_hgvs(ps.contig, f"g.{ps.pos}{ps.ref}>{alt}")
tag_rows[(gene, allele)] = (gene, allele, "GRCh38", ps.contig, ps.pos, ps.ref, alt, hg, rsid, -1, alt)
rows["probe_map"].append((name, ps.name, ps.contig, ps.pos, ps.ref, alt))
# ClinPGx clinical-annotation variants of genes no definition source covers: genotype-only variant-based genes
cp_src = None
cdir2 = _latest(o.sources, "clinpgx")
if cdir2 is not None:
import zipfile
from simurg.pgx.db import clinpgx_variant_genes
cp_src = json.loads((cdir2 / "source.json").read_text())
with zipfile.ZipFile(cdir2 / "clinicalVariants.zip") as z:
tsv = z.read("clinicalVariants.tsv").decode()
known = {g for g, *_ in rows["genes"]} | {g for g, *_ in TAGS}
cp_genes = clinpgx_variant_genes(tsv, known)
resolved: dict[tuple[str, str], tuple[Any, ...]] = {}
want = {rs for v in cp_genes.values() for rs in v}
for bpm_path in o.manifests:
name = read_bpm(bpm_path).manifest_name
for ps in _probe_sites(bpm_path, csvs.get(bpm_path), genome, cmap, {}, rep, want):
alts = [a for a in ps.alleles if a != ps.ref]
if len(alts) != 1 or ps.ref not in ps.alleles:
continue
for rs in re.findall(r"rs\d+", ps.name):
if rs in want:
hg = full_hgvs(ps.contig, f"g.{ps.pos}{ps.ref}>{alts[0]}")
resolved.setdefault((rs, ps.contig), (ps.contig, ps.pos, ps.ref, alts[0], hg))
rows["probe_map"].append((name, ps.name, ps.contig, ps.pos, ps.ref, alts[0]))
by_rs = {rs: v for (rs, _), v in resolved.items()}
lid2 = 950_000_000
for gene in sorted(cp_genes):
chrom = None
rows["alleles"].append((gene, "Reference", "reference", None, None, 0, 1))
for rs in sorted(cp_genes[gene], key=lambda r: int(r[2:])):
v = by_rs.get(rs)
rows["alleles"].append((gene, rs, "rsid_variant_allele", None, None, 0, int(v is not None)))
if v is not None:
lid2 += 1
chrom = v[0]
rows["allele_variants"].append((gene, rs, "GRCh38", v[0], v[1], v[2], v[3], v[4], rs, lid2, v[3]))
rows["genes"].append((gene, "variant_based", chrom))
rep.info(f"ClinPGx: {len(cp_genes)} variant-based gene(s), {len(by_rs)} of {len(want)} rsID(s) resolved "
f"on the manifest(s)") # fmt: skip
if tag_rows: # PGX-007: tag alleles become defined single-variant alleles
rows["allele_variants"] += list(tag_rows.values())
rows["alleles"] = [(g, a, k, f, act, sv, 1 if (g, a) in tag_rows else d) for g, a, k, f, act, sv, d in
@@ -270,7 +311,10 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
"license": csrc.get("license")}]
+ ([{"name": "PharmVar", "version": pv_src["version"],
"retrieved": pv_src["retrieved"], "license": pv_src.get("license")}]
if pv_src else [])),
if pv_src else [])
+ ([{"name": "ClinPGx clinical variants", "version": cp_src["version"],
"retrieved": cp_src["retrieved"], "license": cp_src.get("license")}]
if cp_src else [])),
"phenotype_sources": json.dumps([{"name": "CPIC", "version": csrc["version"]}]),
"cpic_files_sha256": json.dumps(csrc.get("files", {}), sort_keys=True),
"manifests": json.dumps(sorted(manifests)), "definition_notes": json.dumps(problems)} # fmt: skip
@@ -301,8 +345,9 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
rep.info(f"identical PGx DB already present: {final}")
return ExitCode.OK
tmp.replace(final)
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
n_g, n_a, n_v, n_p = (len(rows[k]) for k in ("genes", "alleles", "allele_variants", "probe_map"))
rep.info(f"PGx DB: {n_g} genes, {n_a} alleles, {n_v} variant rows, {n_p} probe links → {final} "
f"(content sha256 {sha})") # fmt: skip
return ExitCode.OK
+13 -5
View File
@@ -17,6 +17,11 @@ from simurg.core.log import Reporter
from simurg.pgx.phenotype import build_table, gene_phenotype
JSON_SCHEMA = "simurg.pgx/1"
# PGX-025 DPWG gene coverage. ClinPGx curates DPWG guidelines with the CPIC allele-function and phenotype mappings for
# genes CPIC covers [DOC: ClinPGx, "Genotype-phenotype mapping for curation of DPWG guidelines"], so those genes use the
# CPIC tables (flag dpwg_via_cpic_mapping); DPWG-only genes have no public machine-readable mapping here.
DPWG_GENES = ("CYP1A2", "CYP2B6", "CYP2C19", "CYP2C9", "CYP2D6", "CYP3A4", "CYP3A5", "DPYD", "NUDT15", "SLCO1B1",
"TPMT", "UGT1A1", "VKORC1", "F5") # fmt: skip
TSV_SCHEMA = "simurg.pgx_tsv/1"
@@ -65,9 +70,8 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
"""``simurg pgx phenotype`` (CLI-034)."""
from simurg.core.atomic import atomic_write_text
if o.guideline != "cpic":
raise SimurgError("E_PGX_GUIDELINE", f"guideline {o.guideline!r} is not in this PGx DB (CPIC only)",
hint="DPWG tables are not built yet (K-029)") # fmt: skip
if o.guideline not in ("cpic", "dpwg"):
raise SimurgError("E_PGX_GUIDELINE", f"unknown guideline {o.guideline!r}", hint="cpic or dpwg")
src = o.calls / "pgx_calls.json"
if not src.exists():
raise SimurgError("E_NO_INPUT", f"{src} not found", hint="run pgx star-allele call first")
@@ -77,9 +81,11 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
meta = db_meta(o.db)
con = sqlite3.connect(o.db)
tables = {}
for (gene,) in con.execute("SELECT DISTINCT gene FROM phenotypes WHERE guideline = ?", (o.guideline,)):
for (gene,) in con.execute("SELECT DISTINCT gene FROM phenotypes WHERE guideline = 'cpic'"):
if o.guideline == "dpwg" and gene not in DPWG_GENES:
continue
rows = con.execute("SELECT lookup_key, activity_score, phenotype FROM phenotypes WHERE gene = ? AND "
"guideline = ?", (gene, o.guideline)).fetchall() # fmt: skip
"guideline = 'cpic'", (gene,)).fetchall() # fmt: skip
alle = con.execute("SELECT allele, function, activity FROM alleles WHERE gene = ?", (gene,)).fetchall()
tables[gene] = build_table(gene, rows, alle)
tested = {g: [a for (a,) in con.execute("SELECT allele FROM alleles WHERE gene = ? AND defined = 1", (g,))]
@@ -104,6 +110,8 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
elif g.get("call_type") == "copy_number":
res, per = None, []
flags = list(g.get("flags", [])) + (res.flags if res else [])
if o.guideline == "dpwg" and g["gene"] in DPWG_GENES:
flags.append("dpwg_via_cpic_mapping" if t is not None else "dpwg_mapping_not_available")
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"],