- simurg/pgx/star.py: solver-free diplotype enumeration incl. CN forms (deletion, xN), wildcards, array-equivalence classes, raw_score/score with CPIC frequency priors; calibrated error model (spec PGX-012 amended). - pipeline/pgx_star.py: DB gene models, SNV + PGx CN VCF evidence, masks, diplotype_summary.tsv + pgx_calls.json. - scripts/pgx: derive_masks.py (array vs 1000G 30x over HTTPS, GS calibration), getrm_truth.py, validate_star_getrm.py. - GSA v4 PGx demo vs GeT-RM (16 CEPH): 100/109 gene calls; CYP2D6/2C19/2C9 41/45 (docs/validation/V2_pgx_star.md). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
69 lines
3.2 KiB
Python
69 lines
3.2 KiB
Python
"""Star-allele calling (spec PGX-012 … PGX-017)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from simurg.pgx.star import CALLED, MASKED, NOT_COVERED, GeneModel, Observations, call_gene, gs_error
|
|
from simurg.pipeline.pgx_star import observations
|
|
|
|
|
|
def _model() -> GeneModel:
|
|
# variants: v0 defines *2, v1 defines *3, v2 is carried by *4 and *3 wildcard, v3 not on the array
|
|
variants = [("1", 100, "A", "G"), ("1", 200, "C", "T"), ("1", 300, "G", "A"), ("1", 400, "T", "C")]
|
|
carries = np.array([[0, 0, 0, 0], # *1
|
|
[1, 0, 0, 0], # *2
|
|
[0, 1, -1, 0], # *3 (v2 wildcard)
|
|
[0, 0, 1, 0], # *4
|
|
[0, 0, 0, 1]], # *5 (only v3, not covered)
|
|
dtype=np.int8) # fmt: skip
|
|
return GeneModel("G", variants, [(f"v{i}", None) for i in range(4)], ["*1", "*2", "*3", "*4", "*5"], carries,
|
|
np.array([0.7, 0.1, 0.1, 0.05, 0.05]), deletion_allele="*0", reference="*1") # fmt: skip
|
|
|
|
|
|
def _obs(dosage: list[int], state: list[int] | None = None) -> Observations:
|
|
st = np.array(state or [CALLED, CALLED, CALLED, NOT_COVERED], dtype=np.int8)
|
|
return Observations(st, np.array(dosage, dtype=np.int8), np.full(4, 0.2), np.full(4, np.nan))
|
|
|
|
|
|
def test_error_model() -> None: # PGX-012 (calibrated, K-029)
|
|
assert gs_error(np.array([0.16, 0.9, 0.1, np.nan])).tolist() == [1e-3, 1e-3, 0.05, 0.05]
|
|
|
|
|
|
def test_simple_diplotypes() -> None: # PGX-013/014
|
|
m = _model()
|
|
sols, feas = call_gene(m, _obs([1, 0, 0, -1]))
|
|
assert sols[0].genotype == "*1/*2" and sols[0].score > 0.9 and feas == [sols[0]]
|
|
sols, _ = call_gene(m, _obs([0, 1, 1, -1]))
|
|
assert sols[0].genotype == "*1/*3" # *3 with its wildcard at v2 explains the v2 het too (vs *3/*4)
|
|
sols, _ = call_gene(m, _obs([2, 0, 0, -1]))
|
|
assert sols[0].genotype == "*2/*2"
|
|
|
|
|
|
def test_array_equivalence() -> None: # PGX-017: *5 differs from *1 only at an uncovered variant
|
|
m = _model()
|
|
sols, _ = call_gene(m, _obs([0, 0, 0, -1]))
|
|
top = sols[0]
|
|
assert top.genotype == "*1/*1" and ("*1", "*5") in top.members and ("*5", "*5") in top.members
|
|
assert abs(sum(s.raw_score for s in sols) - 1) < 1e-9
|
|
|
|
|
|
def test_copy_number_forms() -> None: # PGX-013 structural candidates
|
|
m = _model()
|
|
obs = Observations(np.array([CALLED, CALLED, CALLED, NOT_COVERED], np.int8), np.array([1, 0, 0, -1], np.int8),
|
|
np.full(4, 0.5), np.array([2 / 3, 0.0, 0.0, np.nan])) # fmt: skip
|
|
sols, _ = call_gene(m, obs, cn=3)
|
|
assert sols[0].genotype == "*2x2/*1" and sols[0].copy_number == 3
|
|
obs.baf[0] = 1.0
|
|
sols, _ = call_gene(m, obs, cn=1)
|
|
assert sols[0].genotype == "*2/*0"
|
|
|
|
|
|
def test_masked_record() -> None: # PGX-010
|
|
m = _model()
|
|
recs = {("1", 100): [{"id": "p1,p1b", "ref": "A", "alts": ["G"], "gt": "1/1", "gs": "0.3", "baf": "1"}],
|
|
("1", 200): [{"id": "p2", "ref": "C", "alts": ["T"], "gt": "0/1", "gs": "0.3", "baf": "0.5"}]} # fmt: skip
|
|
obs, ploidy = observations(m, recs, masked={"p1b"})
|
|
assert ploidy == 2 and obs.state[0] == MASKED and obs.state[1] == CALLED and obs.dosage[1] == 1
|
|
assert obs.state[2] == NOT_COVERED
|