PGx: full call detail (PGX-016..020, 027/028, 031), pgx coverage (PGX-009/035), full HGVS
- 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.).
This commit is contained in:
1 parent
f885593e32
commit
e3f784859b
10 files changed
+352
-40
No files matched your search
@@ -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_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_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_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_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_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 |
|
| `W_QC_NO_BAF` | warning | GTC has no B-allele frequencies; contamination not estimated | QC-064 |
|
||||||
|
|||||||
@@ -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
|
* 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
|
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.
|
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
|
* 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
|
genes (CYP2A6, CYP3A4, CYP1A2, …); DPWG tables.
|
||||||
PGX-027/028 (`supporting_variants.tsv`, `unresolved_variants.tsv`, `variants_not_covered.tsv`).
|
|
||||||
@@ -910,6 +910,22 @@ def pgx_annotate_cmd(
|
|||||||
_pgx_phenotype(calls, db, out, guideline, force, log_json, debug, quiet, "pgx annotate")
|
_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")
|
@pgx_cn_app.command("train")
|
||||||
def pgx_cn_train_cmd(
|
def pgx_cn_train_cmd(
|
||||||
gtcs: Annotated[Path, typer.Option("--gtcs", help="Folder searched recursively for training GTCs (required).")],
|
gtcs: Annotated[Path, typer.Option("--gtcs", help="Folder searched recursively for training GTCs (required).")],
|
||||||
|
|||||||
@@ -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_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_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("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"),
|
_m("E_PGX_SOURCE", "error", "PGx source tables missing; run scripts/pgx/fetch_sources.sh", "PGX-001"),
|
||||||
# --- methylation control QC (MET-001 .. MET-018) -------------------------------------------------
|
# --- methylation control QC (MET-001 .. MET-018) -------------------------------------------------
|
||||||
_m("E_METH_NO_ASSAY", "error", "Probe section not found", "MET-002"),
|
_m("E_METH_NO_ASSAY", "error", "Probe section not found", "MET-002"),
|
||||||
|
|||||||
+17
-1
@@ -19,7 +19,23 @@ from typing import Any
|
|||||||
Fetch = Callable[[str, int, int], str]
|
Fetch = Callable[[str, int, int], str]
|
||||||
HGVS_RE = re.compile(r"^([gm])\.(\d+)(?:_(\d+))?(.*)$")
|
HGVS_RE = re.compile(r"^([gm])\.(\d+)(?:_(\d+))?(.*)$")
|
||||||
REPEAT_RE = re.compile(r"^([ACGT]+)\((\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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class Solution:
|
|||||||
raw_score: float = 0.0
|
raw_score: float = 0.0
|
||||||
score: float = 0.0
|
score: float = 0.0
|
||||||
copy_number: int = 2
|
copy_number: int = 2
|
||||||
|
copies: tuple[int, ...] = () # haplotype index of every gene copy of the representative (SNV model)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def genotype(self) -> str:
|
def genotype(self) -> str:
|
||||||
@@ -157,8 +158,8 @@ def call_gene(
|
|||||||
p *= 2
|
p *= 2
|
||||||
pri.append(p)
|
pri.append(p)
|
||||||
best = members[int(np.argmax(pri))]
|
best = members[int(np.argmax(pri))]
|
||||||
sols.append(Solution(cands[best][1], float(ll[best]), float(sum(pri)),
|
sols.append(Solution(cands[best][1], float(ll[best]), float(sum(pri)), [cands[k][1] for k in members],
|
||||||
[cands[k][1] for k in members], copy_number=cn if cn is not None else 2)) # fmt: skip
|
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])
|
lls = np.array([s.ll for s in sols])
|
||||||
lp = lls + np.log([s.prior for s in sols])
|
lp = lls + np.log([s.prior for s in sols])
|
||||||
raw = np.exp(lls - lls.max())
|
raw = np.exp(lls - lls.max())
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
|
|||||||
"""``simurg pgx db build`` (CLI-035)."""
|
"""``simurg pgx db build`` (CLI-035)."""
|
||||||
from simurg.formats.bpm import read_bpm
|
from simurg.formats.bpm import read_bpm
|
||||||
from simurg.formats.fasta import ContigMap, Genome
|
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")
|
cdir = _latest(o.sources, "cpic")
|
||||||
if cdir is None:
|
if cdir is None:
|
||||||
@@ -145,14 +145,14 @@ def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
|
|||||||
continue
|
continue
|
||||||
pos, ref, alt = s.alleles[val]
|
pos, ref, alt = s.alleles[val]
|
||||||
rows["allele_variants"].append(
|
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:
|
for lid in d.wildcards:
|
||||||
rows["allele_wildcards"].append((gene, d.name, lid))
|
rows["allele_wildcards"].append((gene, d.name, lid))
|
||||||
for pop, f in sorted(d.frequencies.items()):
|
for pop, f in sorted(d.frequencies.items()):
|
||||||
rows["frequencies"].append((gene, d.name, pop, f))
|
rows["frequencies"].append((gene, d.name, pop, f))
|
||||||
for lid, s in sorted(sites.items()):
|
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()):
|
for val, (pos, ref, alt) in sorted(s.alleles.items()):
|
||||||
rows["site_values"].append((s.gene, lid, val, pos, ref, alt))
|
rows["site_values"].append((s.gene, lid, val, pos, ref, alt))
|
||||||
results = {r["id"]: r for r in t["gene_result"]}
|
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 "
|
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
|
f"rows, {len(rows['probe_map'])} probe links → {final} (content sha256 {sha})") # fmt: skip
|
||||||
return ExitCode.OK
|
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
|
||||||
@@ -98,15 +98,24 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
|
|||||||
else:
|
else:
|
||||||
res, per = gene_phenotype(t, feas)
|
res, per = gene_phenotype(t, feas)
|
||||||
flags = list(g.get("flags", [])) + (res.flags if res else [])
|
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"],
|
"feasible_solutions": g["feasible_solutions"],
|
||||||
"activity_score": res.activity_score if res else None,
|
"activity_score": res.activity_score if res else None,
|
||||||
"phenotype": res.phenotype if res else None, "per_solution_phenotypes": per,
|
"phenotype": res.phenotype if res else None, "per_solution_phenotypes": per,
|
||||||
"score": g["candidates"][0]["score"] if g["candidates"] else None,
|
"score": top.get("score"), "raw_score": top.get("raw_score"),
|
||||||
"raw_score": g["candidates"][0]["raw_score"] if g["candidates"] else None,
|
|
||||||
"flags": sorted(set(flags)), "copy_number": g.get("copy_number"),
|
"flags": sorted(set(flags)), "copy_number": g.get("copy_number"),
|
||||||
"candidate_solutions": [{"rank": i + 1, **c} for i, c in enumerate(g["candidates"])],
|
"array_equivalent_alleles": [{"allele": a["allele"], "equivalents": a["array_equivalent_alleles"]}
|
||||||
"alleles_tested": sorted(tested.get(g["gene"], []), key=_natural)} # fmt: skip
|
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)
|
genes.append(entry)
|
||||||
ann_rows.append({"sample_id": s["sample_id"], "sample_name": s.get("sample_name", s["sample_id"]),
|
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"],
|
"gene": g["gene"], "guideline": o.guideline, "genotype": g["genotype"],
|
||||||
@@ -126,6 +135,12 @@ def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
|
|||||||
return ExitCode.OK
|
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, ...]:
|
def _natural(a: str) -> tuple[Any, ...]:
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|||||||
+187
-27
@@ -17,12 +17,15 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
from simurg import __version__
|
from simurg import __version__
|
||||||
from simurg.core.errors import ExitCode, SimurgError
|
from simurg.core.errors import ExitCode, SimurgError
|
||||||
from simurg.core.log import Reporter
|
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
|
||||||
|
|
||||||
|
MAX_EDIT_DISTANCE = 3 # PGX-019
|
||||||
|
|
||||||
CN_REGION = {"CYP2D6": "CYP2D6.p5"} # PGX-013: total gene copies from the 5′ region
|
CN_REGION = {"CYP2D6": "CYP2D6.p5"} # PGX-013: total gene copies from the 5′ region
|
||||||
DELETION_ALLELE = {"CYP2D6": "*5"}
|
DELETION_ALLELE = {"CYP2D6": "*5"}
|
||||||
|
|
||||||
@@ -145,6 +148,72 @@ def observations(
|
|||||||
return Observations(state, dosage, gs, baf), ploidy
|
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]:
|
def read_cn_vcf(path: Path) -> dict[str, int | None]:
|
||||||
out: dict[str, int | None] = {}
|
out: dict[str, int | None] = {}
|
||||||
with gzip.open(path, "rt") as fh:
|
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:
|
def run_star_call(o: StarOptions, rep: Reporter) -> int:
|
||||||
"""``simurg pgx star-allele call`` (CLI-033)."""
|
"""``simurg pgx star-allele call`` (CLI-033)."""
|
||||||
from simurg.core.atomic import check_outputs_absent
|
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)
|
snv, cnv = _discover(o.vcfs)
|
||||||
if not snv:
|
if not snv:
|
||||||
@@ -195,7 +264,10 @@ def run_star_call(o: StarOptions, rep: Reporter) -> int:
|
|||||||
for m in models.values():
|
for m in models.values():
|
||||||
for c, p, _, _ in m.variants:
|
for c, p, _, _ in m.variants:
|
||||||
wanted[c].add(p)
|
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 = []
|
calls_json = []
|
||||||
for sid, path in sorted(snv.items()):
|
for sid, path in sorted(snv.items()):
|
||||||
recs, meta = read_vcf_sites(path, wanted)
|
recs, meta = read_vcf_sites(path, wanted)
|
||||||
@@ -215,42 +287,130 @@ def run_star_call(o: StarOptions, rep: Reporter) -> int:
|
|||||||
sample_name = sid
|
sample_name = sid
|
||||||
genes_out = []
|
genes_out = []
|
||||||
for gene, m in sorted(models.items()):
|
for gene, m in sorted(models.items()):
|
||||||
obs, ploidy = observations(m, recs, masked)
|
gene_json, g_rows, g_sup, g_unres, g_ncov = call_one_gene(sid, sample_name, m, recs, masked, cn, o)
|
||||||
region = CN_REGION.get(gene)
|
if gene_json is None:
|
||||||
gcn = cn.get(region) if region else None
|
continue
|
||||||
sols, feasible = call_gene(m, obs, cn=gcn if region else None, ploidy=ploidy,
|
genes_out.append(gene_json)
|
||||||
max_candidates=o.max_candidates, feasible_ratio=o.feasible_ratio) # fmt: skip
|
rows += g_rows
|
||||||
flags = []
|
sup_rows += g_sup
|
||||||
low = [k for k in range(len(m.variants)) if obs.state[k] == CALLED and obs.gs[k] < o.low_gs]
|
unres_rows += g_unres
|
||||||
if low:
|
for x in g_ncov:
|
||||||
flags.append("review_alternatives")
|
not_cov_rows[(gene, str(x["hgvs"]))] = x
|
||||||
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
|
|
||||||
calls_json.append({"sample_id": sid, "genes": genes_out})
|
calls_json.append({"sample_id": sid, "genes": genes_out})
|
||||||
cols = ["sample_id", "sample_name", "solution_id", "gene", "call_type", "solution", "array_equivalent_alleles_1",
|
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",
|
"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
|
"variant_change", "score", "raw_score", "copy_number_solution", "flags"] # fmt: skip
|
||||||
o.out.mkdir(parents=True, exist_ok=True)
|
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,
|
(o.out / "pgx_calls.json").write_text(json.dumps({"software": f"SimurgArray {__version__}", "db": o.db.name,
|
||||||
"samples": calls_json}, indent=1)) # fmt: skip
|
"samples": calls_json}, indent=1)) # fmt: skip
|
||||||
rep.info(f"star alleles: {len(calls_json)} sample(s), {len(models)} gene(s)")
|
rep.info(f"star alleles: {len(calls_json)} sample(s), {len(models)} gene(s)")
|
||||||
return ExitCode.OK
|
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]:
|
def _sol_json(s: Solution) -> dict[str, Any]:
|
||||||
return {"genotype": s.genotype, "score": round(s.score, 4), "raw_score": round(s.raw_score, 4),
|
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
|
"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))
|
||||||
@@ -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())
|
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 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",)
|
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"
|
||||||
Reference in new issue
Block a user