46 Commits
Author SHA1 Message Date
kemal af39d0ee6a Release 2.0.0: v2 modules (ArrayTrain, CN/LOH, PGx, methylation QC)
ci / test (3.13) (push) Failing after 16s
ci / test (3.12) (push) Failing after 9s
User guide: ArrayTrain section, v2 commands in the table, v2 accuracy rows,
updated limitations. README status for v2.0.0. Version 2.0.0 (Python package);
the Rust kernels are unchanged and stay simurg_core 1.1.0. uv.lock committed
(uv lock --check clean).

Full suite on the Pi, file by file under the memory guard: 29 files exit 0
(unit: 282 passed), ruff and mypy clean.
2026-09-30 06:24:12 +01:00
kemal 077619a6f1 test_p6_vcf: stream the gtc2vcf oracle output (OUT-012)
The INFO/extra-FORMAT comparison held ~700 000 gtc2vcf records as text plus two
dicts each (> 4.8 GB, stopped by the Pi memory guard). Now written to a temp
file and kept as compact tuples: peak 3.0 GB; all 5 tests pass.
2026-09-30 01:25:32 +01:00
kemal 6d22b5e19b CN: final full-cohort cloud validation (GSE165845, CYTO-041/042 defaults)
360 arrays vs NYGC 30x SV: all samples OK (K-027 gate fix), PASS precision
DEL 44 % / DUP 57 % (first run 39 % / 34 % with filters ignored), 61 % without
the one excess_events array; rare DEL sensitivity 30 %; LOH replicated 85 %.
m7g.4xlarge spot, 17 min. docs/validation/V2_cn.md, K-027.
2026-09-29 23:04:16 +01:00
kemal 1eb7ea8154 ArrayTrain: BIC-selected unbounded θ mixtures + HWE-guided labelling
Fixed θ windows mislabelled compressed/asymmetric loci (2-cluster loci: 1418 vs
186 errors). Now 1/2/3-component mixtures chosen by BIC, labelled by HWE
goodness of fit plus weak position and gap priors, split clusters merged.
GSE165845 chr22 vs 1000G 30x (240 arrays): concordance 99.44 % (v1 99.27 %,
vendor EGT 99.46 %), NRD 1.30 % (1.89 %, 1.23 %), call rate 99.61 %.
docs/validation/V2_arraytrain.md, K-030; plan §12, CLI-038.
2026-09-29 22:40:19 +01:00
kemal adad0efb08 train: loop variable typing (mypy) 2026-09-29 19:54:24 +01:00
kemal 7cfb0fd962 ArrayTrain (plan §12): simurg train — EGT cluster files from a cohort's GTCs
- simurg/train/clusters.py: vectorised per-locus θ mixture (AA/AB/BB + outlier component, θ-ordered bounds),
  cluster selection and merging, lone clusters homozygous, missing clusters imputed by cross-locus regression
  per assay type, cluster_sep and train (GenTrain-field) scores.
- formats/egt.py: write_egt (EGT v3, data block 9, round-trips through the reader).
- pipeline/train.py: normalised θ/R from GTC raw X/Y + transforms, sex-aware (X females, Y males, MT haploid),
  intensity-only loci as one R cluster.
- scripts/train/concordance_chr.py: array vs 1000G 30x on one chromosome.
Demo (36 GSA v4 PGx arrays, chr22 vs 1000G): trained 99.29 % / NRD 2.6 % vs vendor EGT 99.57 % / 1.55 %.
2026-09-29 19:53:53 +01:00
kemal cfae926fbc PGx validation on GSE165845: WGS-derived star truth, 2-fold masks, array-resolvable concordance
- scripts/pgx/wgs_star_truth.py: star alleles from the 1000G 30x phased panel with the DB definitions
  (wildcard-aware haplotype assignment; checked against GeT-RM on the demo CEPH individuals).
- validate_star_getrm.py: --all-genes, --individuals, --coverage (projection onto what the array can see);
  replicate_concordance.py.
- 120 individuals x 3 GSA v1 arrays: array-resolvable 1637/1710 (95.7 %), top 1223/1800 (chip lacks many
  defining variants), GeT-RM subset 28/35, replicates 99.2 % without the failed array (V2_pgx_star.md, K-029).
2026-09-29 19:00:34 +01:00
kemal 3fa97df61e CHANGELOG: v2 CN/LOH/mosaicism and pharmacogenomics 2026-09-29 17:16:10 +01:00
kemal 151723d5e1 docs: user guide sections for CN/LOH/mosaicism and pharmacogenomics 2026-09-29 17:15:43 +01:00
kemal d60a1d5180 tests: PGx demo oracle tests — definition sanity (PGX-023), limitations (PGX-009), determinism (PGX-029), JSON/TSV (PGX-028) 2026-09-29 17:15:09 +01:00
kemal ba8454e2c8 pgx_cn: PFB as float64 for the BAF evidence (mypy) 2026-09-29 17:11:50 +01:00
kemal 1cb7b59559 PGx CN: scan-date batch warning (PCN-006), CN-consistent BAF evidence (PCN-010, --bafs-in-cn),
noise-outlier exclusion + robustness test (PCN-017/EDGE-026), platform from the CSV manifest (CLI-032)

Also: star-allele calls are checked for one manifest/build per run (PGX-011), duplications at the CN cap are
named xN (PGX-022), and tandem names attach supporting variants to their full copy.
2026-09-29 17:11:34 +01:00
kemal ed5e92b859 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
kemal 9a39ae62fe PGx: PharmVar genes (CYP1A2, CYP2A6, CYP2A13, CYP2C8, CYP3A4), CYP2A6 *4 from CN (PGX-001/006)
fetch_sources.sh downloads PharmVar per-gene zips; pgx db build adds core alleles for genes CPIC does not
define (normalised against the FASTA, *1 = reference). Masks re-derived with the new variants. GeT-RM:
CYP3A4 15/15, CYP2A6 7/9; all comparable genes 154/168 (CYP1A2/CYP2C8 definitions differ from GeT-RM's).
2026-09-29 16:50:27 +01:00
kemal b96ae21c70 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.
2026-09-29 16:29:54 +01:00
kemal 9a02f39d01 PGx: all CPIC gene kinds — G6PD, variant-based, HLA tags, *0 CN genes (PGX-006/007/021)
- DB build resolves the PGX-007 tag variants from the manifest's probes; coverage over all gene kinds.
- Variant-based genes reported per variant (rs.. REF/ALT | Reference) with the CPIC lowest-activity diplotype;
  tagging genes as positive/negative with the population caveat; GSTM1/GSTT1/UGT2B17 *0 from targeted CN.
- Case-insensitive phenotype keys (MT-RNR1). GeT-RM check extended to VKORC1 and the CN genes:
  131/144 gene calls (docs/validation/V2_pgx_star.md).
2026-09-29 16:24:33 +01:00
kemal e3f784859b 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.).
2026-09-29 16:16:39 +01:00
kemal f885593e32 PGx phenotypes: pgx phenotype|annotate (PGX-024..028, CLI-034)
CPIC lookups by allele function counts or activity values (duplications N x activity, *5/*0 = 0, total-score
fallback), Indeterminate when feasible solutions disagree or an allele is not in the guideline; per-sample
simurg.pgx/1 JSON and diplotype_annotations.tsv with PGX-026 metadata lines.
2026-09-29 16:03:27 +01:00
kemal 08d7fe6840 derive_masks: keep pysam's downloaded tabix indexes in data/pgx/cache, not the working directory 2026-09-29 16:00:25 +01:00
kemal feb1d993b5 PGx star alleles: pgx star-allele call (PGX-011..021), probe masks vs 1000G, GeT-RM validation (K-029)
- 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).
2026-09-29 14:08:03 +01:00
kemal 0c5dd03ca8 PGx DB: pgx db build from CPIC (PGX-001..005, IN-053, CLI-035)
- scripts/pgx/fetch_sources.sh: CPIC API tables (CC0) into a dated folder with version and hashes.
- simurg/pgx/db.py: CPIC HGVS locations and values -> left-normalised VCF variants (SNV, del, ins, dup,
  repeats, delins, chrM); IUPAC values are core-allele wildcards; alleles without definitions are flagged.
- pipeline/pgx_db.py: deterministic SQLite (content-hash name), probe_map via the SNV VCF site mapper;
  GSA v4 PGx: 22 genes, 1354 alleles, 5002 probe links; two builds byte-identical.
2026-09-29 13:24:54 +01:00
kemal c3e0681b6e PGx CN: pgx copy-number train|call (CLI-031/032, PCN-001..017, IN-050), region BED v1, demo identities (K-028)
- simurg/pgx/cn.py: per-region CN mixture, mu_c = delta + gamma*log2((c+1)/3), free lower CN-0 component,
  Hardy-Weinberg-constrained weights over haplotype copy numbers, EM from a grid of label starts.
- pipeline/pgx_cn.py: model file (.simurg-pgxcn), batch rules (PCN-004), per-sample CN VCF + tbi, batch and
  sample summaries.
- The GSA v4 PGx demo arrays are 30 CEPH/1000G individuals (scripts/pgx/demo36_identity.tsv); against NYGC 30x
  SV RD_CN 224/240 region calls agree (GSTM1, UGT2B17 30/30), in-sample (docs/validation/V2_pgx_cn.md).
2026-09-29 13:16:19 +01:00
kemal 518bfbe059 V2 CN: CYTO-042 results (precision up, mosaic calls halved); pi_run reports process memory without page cache 2026-09-29 12:20:40 +01:00
kemal 924b6bb4b7 cn build-model: compute the per-probe LRR spread in column chunks (peak memory hit the 5 GB cap on 120 GSA arrays) 2026-09-29 12:11:38 +01:00
kemal da876b628e CN: per-probe LRR noise from the batch reference (CYTO-042), --probe-lrr-sd/--no-probe-lrr-sd 2026-09-29 12:00:04 +01:00
kemal d5ab45886c cn_validate_truth: sensitivity by carrier frequency (rare < 5 % is the primary metric, K-027) 2026-09-29 11:57:58 +01:00
kemal fa36e550c9 V2 CN validation: Pi tuning rounds, CYTO-041 effect, common-CNV sensitivity limit (K-027) 2026-09-29 11:11:14 +01:00
kemal 803f4aaec3 CN: MF split normalised by per-probe reference BAF noise; genotype-based complete loss (CYTO-041)
The mosaic BAF split is now estimated over heterozygous calls against each probe's expected noise (the
reference heterozygous spread scaled to the sample), inverting the median of |N(+-d, sigma)|. A mosaic loss
lacking the heterozygous calls its PFB predicts gets MF 1 and is promoted (CYTO-020).
2026-09-29 10:56:21 +01:00
kemal 6bc42e53b7 CN: noise-corrected BAF split and complete-loss MF (CYTO-041); Pi tuning harness
Mosaic fraction from the BAF split now subtracts the heterozygous BAF noise (median |BAF-0.5| of unsplit
hets = 0.6745 sigma_b), and a mosaic loss lacking its expected heterozygotes gets MF 1 and is promoted to a
constitutional DEL (CYTO-020). Adds scripts/v2/cn_tune.sh + cn_tune_report.py (GSE165845 rounds on the Pi).
2026-09-29 10:30:12 +01:00
kemal 98e16cf501 pi_run: hard per-job memory cap via a systemd scope (MemoryMax) when the memory cgroup is enabled
The Pi now boots with cgroup_enable=memory cgroup_memory=1; the MemAvailable guard stays as a
second layer and as the fallback on systems without the memory controller.
2026-09-29 08:24:29 +01:00
kemal 98f981b59e CN: per-probe heterozygous BAF spread from the batch reference; --mosaic-prior/--switch-distance; Pi memory guard
scripts/pi_run.sh runs heavy jobs in their own process group and stops them when
MemAvailable drops below a floor (Raspberry Pi OS boots with cgroup_disable=memory, so a
systemd MemoryMax cap is not available); a 360-sample genotype run with cohort QC had
exhausted memory and the OOM killer took down the desktop session.
2026-09-29 07:56:12 +01:00
kemal 6ad0c53664 v2 CN: real-data validation vs NYGC 30x SVs (interim), noise gate on normalised LRR (K-027)
GSE165845 360 arrays: constitutional DEL/DUP calls 77/74 % in the NYGC SV truth, LOH
reproduced across replicates 74 %; mosaic calls are mostly false positives (12-23 %),
sensitivity for 25 kb-1 Mb events 7-13 % at QUAL >= 20. All samples had been flagged
SampleQuality by the summary logr_dev (cluster-file mismatch); the gate now uses the SD
of the LRR after the batch reference. Validation script: --ignore-filter.
2026-09-29 07:42:06 +01:00
kemal 1e41acef27 test: packaged CN data files 2026-09-29 07:07:03 +01:00
kemal b8e3cf233a Track the packaged CN data files (the data/ ignore rule matched src/simurg/cn/data)
Found by the CN cloud validation: mask_GRCh3{7,8}.bed and cytoBand_GRCh3{7,8}.txt.gz were
never committed nor shipped in the wheel. The ignore rule now covers only the top-level data/.
2026-09-29 07:06:47 +01:00
kemal b6e8177e5a v2 CN: truth validation script (NYGC 30x SV freeze V3) and cloud task cnv 2026-09-29 01:39:49 +01:00
kemal 0bf5576049 v2 CN: cn annotate (JSON report, ISCN 2020, cytobands, genes) and the annotation bundle builder
cn call now records per-chromosome and sample statistics (percent het, LRR stats, BAF
deviations, robust LogR dev, call rate) in the sidecar; cn annotate writes
<sample>.cyto.json (CYTO-033) with ISCN microarray/traditional strings (CYTO-034), bands
and RefSeq gene lists (CYTO-035), LOH summaries, weighted median CN (CYTO-026); annotation
bundle from UCSC cytoBand + ncbiRefSeq (CYTO-036, IN-054). Tests for ISCN strings, gene
overlap, sidecar-missing error and an end-to-end call+annotate.
2026-09-29 01:37:56 +01:00
kemal 615a6139bf v2 CN: unit tests (GC windows, PFB, GC-wave, smoothing, HMM DEL/LOH, effective size, thresholds, mask, CLI VCF rules) 2026-09-29 01:32:56 +01:00
kemal 70001a0d6c v2 CN: synthetic implant evaluation (CYTO-022); reference GTC discovery follows symlinks
216 implanted events in 22 synthetic GSA samples (4 replicates per kind x fraction x
size; reference model from 30 other samples): constitutional DEL/DUP 16/16 at 5-20 Mb,
mosaic loss/gain/CN-LOH at 15 % / 5 Mb 12/12.
2026-09-29 01:30:07 +01:00
kemal edb59e4ee2 v2 CN/LOH (WIP): cn build-model and cn call
Cyto model (.simurg-cyto: GC 1 kb/100 kb/1 Mb, PFB from the cluster file, optional
sex-aware batch reference from normal GTCs; IN-051, CYTO-038); GC-wave correction
(CYTO-003); smoothing (CYTO-004); HMM with constitutional, gain-LOH, mosaic and haploid
states, forward-backward QUAL and Viterbi segments (CYTO-005/012); event statistics,
effective size, post-filters, per-type thresholds, promotion/LowFraction, built-in
centromere mask from UCSC cytoBand (CYTO-006..021); sex resolution (CYTO-024), ploidy;
CNV VCF with bgzip+tabix (CYTO-028..031), segments parquet, cn_sample_summary.tsv and
the cn_call_manifest.json sidecar.
2026-09-29 01:13:51 +01:00
kemal 5164124438 v2 methylation QC validated on minfiDataEPIC and against sesame (K-026)
beta r 0.99998 vs sesame 1.28.1 (max |dbeta| 0.011), detection calls 99.1% identical,
noob within 0.5% median; replicate r 0.997; all 21 control metrics pass. Spec deviations
recorded (5-field control lines, pOOBAH formula, minfi sheet keys). User guide section.
2026-09-29 00:41:54 +01:00
kemal 6aebb31991 methylation: combine_neg option; oracle compares both background variants and logs sesame's negative controls 2026-09-29 00:36:44 +01:00
kemal 3a816a1c44 sesame oracle: compare without bead-count masking on both sides 2026-09-29 00:29:25 +01:00
kemal 1aa9459525 sesame oracle: cache EPIC annotation up front 2026-09-29 00:23:24 +01:00
kemal 03587b2b0a sesame oracle: explicit platform, cache fallback 2026-09-29 00:18:17 +01:00
kemal da4a67b59b v2 methylation: sesame oracle scripts and cloud task 2026-09-29 00:14:18 +01:00
kemal 3eb51639cc v2 methylation control QC (WIP): simurg methyl qc|convert-manifest (MET-001..018)
Methylation CSV manifest reader (MET-002/003), raw M/U assembly with bead masking
(MET-005), pOOBAH detection p-values, noob background correction, linear/nonlinear dye
bias, beta and M-values (MET-010..012), the 21 methylation control metrics (MET-006,
bs-conv red-bg set option), per-sample ctrl/cgs tables, summary tsv/xlsx/html with the
detection histogram and a beta PCA, pcs.tsv.gz, controls_raw/controls_qc.tsv; methylation
datasets load in qc report (MET-014); qc controls --array-type methylation; minfi-style
Sentrix_ID/Sentrix_Position sheet keys. Vendor EPIC manifests carry a 5th field on some
control lines: >= 4 fields accepted. Runs on the public minfiDataEPIC set (3 x NA12878).
2026-09-29 00:13:22 +01:00
88 changed files with 15988 additions and 50 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# vendor / large data — never commit # vendor / large data — never commit
data/ /data/
refs/ refs/
_phase0_archive/ _phase0_archive/
*.idat *.idat
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## 2.0.0 — 2026-09-30 (v2 modules)
The Python package is 2.0.0. The optional Rust kernels are unchanged and stay `simurg_core` 1.1.0.
**Methylation control QC** (`simurg methyl qc`, `simurg methyl convert-manifest`; MET-001 … MET-018): methylation
CSV manifests, the 21 control metrics, pOOBAH detection p-values, noob, linear/nonlinear dye bias, β and M-values,
summary tsv/xlsx/html with a β PCA, per-sample tables; methylation folders load in `simurg qc report`;
`qc controls --array-type methylation`. Checked against sesame (`docs/validation/V2_methylation.md`, K-026).
**Copy number, LOH and mosaicism** (`simurg cn build-model|call|annotate`; CYTO-001 … CYTO-042): GC-wave
correction, sex-aware batch reference, HMM with constitutional, LOH and mosaic states, event statistics and
filters, CNV VCF + segments + sidecar, ISCN 2020 annotation from UCSC tables; noise-normalised mosaic fractions and
complete-loss promotion (CYTO-041), per-probe LRR noise (CYTO-042). Validated on implanted events and on 360
GSE165845 arrays against the NYGC 30x SV callset (`docs/validation/V2_cn.md`, K-027).
**Pharmacogenomics** (`simurg pgx db build|coverage`, `pgx copy-number train|call`, `pgx star-allele call`,
`pgx phenotype`; PCN-001 … PCN-017, PGX-001 … PGX-035): targeted CN for CYP2D6 sub-regions, CYP2A6, GSTM1, GSTT1,
UGT2B17, SULT1A1, CYP2E1 (HWE-constrained compression mixture); a deterministic PGx DB from CPIC, PharmVar and
ClinPGx (70 genes); solver-free diplotype calling with array-equivalence classes, CYP2D6 structures (*5, xN,
*68+*4, *36+*10, *13), G6PD, HLA tags, variant-based and *0 genes; probe masks from 1000G 30x concordance; CPIC and
DPWG phenotypes; per-sample JSON and the TSV set. The GSA v4 PGx demo arrays are 30 CEPH/1000G individuals: region
CN 224/240 vs NYGC SV, gene calls 154/168 vs GeT-RM (`docs/validation/V2_pgx_cn.md`, `V2_pgx_star.md`, K-028, K-029).
**ArrayTrain** (`simurg train`; plan §12): trains EGT cluster files (v3, dbv9) from a cohort's GTCs with sex-aware
sample selection, BIC-selected 1/2/3-cluster θ mixtures, HWE-guided labelling, and missing clusters imputed from
cross-locus relations. On GSE165845 (chr22 vs 1000G 30x) it gives 99.44 % concordance at a 99.61 % call rate;
the vendor EGT gives 99.46 % at 99.15 % (`docs/validation/V2_arraytrain.md`, K-030).
## 1.1.0 — 2026-09-28 (P9, P10) ## 1.1.0 — 2026-09-28 (P9, P10)
**Packaging (P10).** `scripts/build_dist.sh` (sdist, pure wheel, manylinux_2_28 kernel wheels for x86_64 and **Packaging (P10).** `scripts/build_dist.sh` (sdist, pure wheel, manylinux_2_28 kernel wheels for x86_64 and
+2 -2
View File
@@ -1,6 +1,6 @@
# SimurgArray container: the `simurg` CLI with the compiled kernels. # SimurgArray container: the `simurg` CLI with the compiled kernels.
# docker build -t simurg:1.1.0 . # docker build -t simurg:2.0.0 .
# docker run --rm -v "$PWD":/work simurg:1.1.0 genotype --bpm M.bpm --egt C.egt --idats idats --out run/gtc # docker run --rm -v "$PWD":/work simurg:2.0.0 genotype --bpm M.bpm --egt C.egt --idats idats --out run/gtc
# Inputs and outputs live in the mounted /work folder; the image holds no data. # Inputs and outputs live in the mounted /work folder; the image holds no data.
ARG PYTHON=3.12 ARG PYTHON=3.12
+15 -9
View File
@@ -1,7 +1,8 @@
# SimurgArray # SimurgArray
Personal research toolkit for Illumina Infinium genotyping BeadChip data Personal research toolkit for Illumina Infinium genotyping BeadChip data
(IDAT → normalization → genotype calling → BAF/LRR → QC → VCF/PLINK; later CN/LOH, PGx, cluster training). (IDAT → normalization → genotype calling → BAF/LRR → QC → VCF/PLINK; CN/LOH/mosaicism, PGx, cluster training,
methylation control QC).
- **SIMURGARRAY_MASTER_PLAN.md** — architecture, algorithms, roadmap - **SIMURGARRAY_MASTER_PLAN.md** — architecture, algorithms, roadmap
- **SIMURGARRAY_FUNCTIONAL_SPEC.md** — 416 itemised requirements (CLI, inputs, outputs, QC, CN, PGx, methylation, edge cases) - **SIMURGARRAY_FUNCTIONAL_SPEC.md** — 416 itemised requirements (CLI, inputs, outputs, QC, CN, PGx, methylation, edge cases)
@@ -9,9 +10,10 @@ Personal research toolkit for Illumina Infinium genotyping BeadChip data
## Status ## Status
**v1.1.0** (2026-09-28) — see `CHANGELOG.md`. IDAT → normalization → GenCall-equivalent genotype calling → GTC v5 → **v2.0.0** (2026-09-30) — see `CHANGELOG.md`. v1: IDAT → normalization → GenCall-equivalent genotype calling →
sample/control QC with an HTML report → plus-strand VCF/BCF → PLINK 1/2, bedGraph, final report; plus a GTC v5 → sample/control QC with an HTML report → plus-strand VCF/BCF → PLINK 1/2, bedGraph, final report; plus a
differential-testing harness. Validation (`docs/validation/`): differential-testing harness. v2 adds cluster training (ArrayTrain), genome-wide CN/LOH/mosaic calling,
pharmacogenomics (targeted CN, star alleles, phenotypes) and methylation control QC. Validation (`docs/validation/`):
| Check | Result | | Check | Result |
|---|---| |---|---|
@@ -19,13 +21,17 @@ differential-testing harness. Validation (`docs/validation/`):
| GSA demo vs gtc2vcf, GTCtoVCF, plink2 | zero unexplained differences | | GSA demo vs gtc2vcf, GTCtoVCF, plink2 | zero unexplained differences |
| 1000 Genomes pedigrees (Omni2.5) | 717/717 parent–offspring pairs; Mendel error median 0.031 % | | 1000 Genomes pedigrees (Omni2.5) | 717/717 parent–offspring pairs; Mendel error median 0.031 % |
| 360 GSA arrays (GSE165845) vs NYGC 30x WGS / GIAB HG001 | concordance 0.9934 / 0.9985; replicates 0.9966 | | 360 GSA arrays (GSE165845) vs NYGC 30x WGS / GIAB HG001 | concordance 0.9934 / 0.9985; replicates 0.9966 |
| ArrayTrain EGT, GSE165845 chr22 vs 1000G 30x | 99.44 % concordance (vendor EGT 99.46 %), call rate 99.61 % |
| CN, 360 GSE165845 arrays vs NYGC 30x SV | PASS precision DEL 44 % / DUP 57 % |
| PGx, GSA v4 demo vs GeT-RM | 154/168 gene calls |
| Methylation QC vs sesame | β r = 0.99998 |
Commands: `simurg inspect`, `ingest`, `normalize`, `genotype`, `qc controls|samples|contamination|relatedness|loci|report|suggest-thresholds`, Commands: `simurg inspect`, `ingest`, `normalize`, `genotype`, `qc controls|samples|contamination|relatedness|loci|report|suggest-thresholds`,
`gtc-to-vcf`, `export plink|bedgraph|finalreport|locus-summary`, `compare`. **User guide: `docs/user_guide.md`**; `gtc-to-vcf`, `export plink|bedgraph|finalreport|locus-summary`, `compare`, `train`, `cn build-model|call|annotate`,
worked example: `examples/gsa_demo.sh`. P9 (performance) is done: optional Rust kernels, byte-identical outputs, `pgx db build|coverage|copy-number|star-allele|phenotype`, `methyl qc|convert-manifest`. **User guide:
~80 GSA samples/min per server core (`docs/validation/P9.md`). P10 (packaging) is done: wheels, Docker image and `docs/user_guide.md`**; worked example: `examples/gsa_demo.sh`. Optional Rust kernels (byte-identical outputs,
conda recipes, verified on fresh x86_64 and aarch64 machines (`docs/validation/P10.md`). Next (plan §18): v2 modules ~80 GSA samples/min per server core, `docs/validation/P9.md`); wheels, Docker image and conda recipes
(cluster training, CN/LOH, PGx). (`docs/validation/P10.md`).
## Quick start ## Quick start
+23 -1
View File
@@ -2435,6 +2435,23 @@ about **algorithms or numerics**, the plan wins. Each such conflict found during
Each yields valid, possibly empty, VCF and JSON output (EDGE-024). (src: E-35) Each yields valid, possibly empty, VCF and JSON output (EDGE-024). (src: E-35)
- **CYTO-041** `§10.3 · v2` **MF refinements [OURS].** Two refinements of CYTO-018, from real-data validation
(docs/validation/V2_cn.md, K-027):
- *noise-corrected split:* per-probe expected noise σ_i = the reference's heterozygous BAF spread of the probe ×
the sample's scale (median |BAF − 0.5| / spread over autosomal heterozygous calls, ÷ 0.6745; the sample's
heterozygous BAF SD without a reference spread). Over the event's heterozygous calls, m = median(|BAF − 0.5| /
σ_i); the split in σ units δ solves Φ(m − δ) − Φ(−m − δ) = ½ (δ = 0 when m ≤ 0.6745) and d = median(σ_i)·δ.
Without this, noise alone gave MF ≈ 0.1–0.3 and genomic waves were called as mosaic losses.
- *complete loss:* a mosaic loss whose called genotypes hold fewer than a quarter of the heterozygotes the PFB
predicts (Σ 2p(1−p) ≥ 3) has MF = 1. With the default `--max-mosaic-fraction 1.0` it is therefore promoted
(CYTO-020) to a constitutional DEL with `HIGHFRACTION`. This catches constitutional deletions whose LRR is
compressed towards the mosaic states.
- **CYTO-042** `§10.2 · v2` **Per-probe LRR noise [OURS].** A model built with `--reference-gtcs` stores each
probe's robust LRR SD across the reference (1.4826·MAD after removing each sample's median and the probe's
reference median). `cn call` scales the sample's LRR emission SD per probe by that spread relative to the autosomal
median spread, clipped to [0.5, 3]. Without the column, or with `--no-probe-lrr-sd`, every probe uses the sample SD.
--- ---
## 7. Targeted PGx copy number (v2) ## 7. Targeted PGx copy number (v2)
@@ -2742,7 +2759,10 @@ about **algorithms or numerics**, the plan wins. Each such conflict found during
In a region whose CN is n ≠ 2, the variant copy number `k ∈ {0..n}` is the maximum-likelihood value under the In a region whose CN is n ≠ 2, the variant copy number `k ∈ {0..n}` is the maximum-likelihood value under the
observed BAF ~ N(k/n, σ²), with σ estimated per batch from AB clusters. It is reported as `variant_copy_number`. observed BAF ~ N(k/n, σ²), with σ estimated per batch from AB clusters. It is reported as `variant_copy_number`.
GS enters as a per-call error rate: ε(GS) = 0.5·(1 − GS)², clipped to [1e-4, 0.25] [OURS]. (src: B-30, E-41) GS enters as a per-call error rate: ε = 1e-3 for GS ≥ 0.15 and 0.05 below [OURS, calibrated: after probe masking
(PGX-010), 0 of 9 740 GSA v4 PGx calls at DB variants disagreed with 1000G 30x in any GS bin, so errors are
probe-specific and belong in the mask; the earlier ε(GS) = 0.5·(1 − GS)² made correct low-GS calls such as
CYP2C9 rs1799853 at GS 0.16 lose to the prior; K-029]. (src: B-30, E-41)
- **PGX-013** `§11 · v2` **Candidate enumeration [DIFF: solver-free].** - **PGX-013** `§11 · v2` **Candidate enumeration [DIFF: solver-free].**
- For each gene, SimurgArray enumerates diplotypes from the gene's haplotypes: star alleles, `*0`, duplications - For each gene, SimurgArray enumerates diplotypes from the gene's haplotypes: star alleles, `*0`, duplications
@@ -3710,6 +3730,8 @@ Generated from the item tags (`build.py`). Phases: P0–P10 as in plan §18; `v2
| CYTO-038 | Model building | §10 | v2 | A-G52 | | CYTO-038 | Model building | §10 | v2 | A-G52 |
| CYTO-039 | Batch scale | §10, §19 | v2 | D-9, E-35 | | CYTO-039 | Batch scale | §10, §19 | v2 | D-9, E-35 |
| CYTO-040 | Degenerate samples | §10 | v2 | E-35 | | CYTO-040 | Degenerate samples | §10 | v2 | E-35 |
| CYTO-041 | MF refinements | §10.3 | v2 | K-027 |
| CYTO-042 | Per-probe LRR noise | §10.2 | v2 | K-027 |
| PCN-001 | Region catalogue | §11 | v2 | B-23, C-G25, D-10 | | PCN-001 | Region catalogue | §11 | v2 | B-23, C-G25, D-10 |
| PCN-002 | Genotype every region [EMU] | §11 | v2 | B-23 | | PCN-002 | Genotype every region [EMU] | §11 | v2 | B-23 |
| PCN-003 | Inputs | §11 | v2 | A-G37 | | PCN-003 | Inputs | §11 | v2 | A-G37 |
+5
View File
@@ -13,7 +13,12 @@ used only as external subprocess oracles and are not copied or linked.
| [Illumina GTCtoVCF](https://github.com/Illumina/GTCtoVCF) — Copyright Illumina, Inc. | Apache-2.0 | Indel source-sequence handling ported to `simurg/harmonize/sites.py` (design-strand orientation, left shift, IUPAC flank matching, deletion/insertion context score); multi-probe consistency idea in `simurg/harmonize/combine.py`; test oracle (run under Python 3 with two local compatibility patches, never shipped) | | [Illumina GTCtoVCF](https://github.com/Illumina/GTCtoVCF) — Copyright Illumina, Inc. | Apache-2.0 | Indel source-sequence handling ported to `simurg/harmonize/sites.py` (design-strand orientation, left shift, IUPAC flank matching, deletion/insertion context score); multi-probe consistency idea in `simurg/harmonize/combine.py`; test oracle (run under Python 3 with two local compatibility patches, never shipped) |
| [Picard `picard.arrays`](https://github.com/broadinstitute/picard) | MIT | Second format reference | | [Picard `picard.arrays`](https://github.com/broadinstitute/picard) | MIT | Second format reference |
| [PyO3](https://github.com/PyO3/pyo3), [rust-numpy](https://github.com/PyO3/rust-numpy), [ndarray](https://github.com/rust-ndarray/ndarray) and their Rust dependencies | MIT / Apache-2.0 / BSD-2-Clause | Compiled into the optional `simurg_core` extension (`rust/simurg-core`); list with `cargo tree` | | [PyO3](https://github.com/PyO3/pyo3), [rust-numpy](https://github.com/PyO3/rust-numpy), [ndarray](https://github.com/rust-ndarray/ndarray) and their Rust dependencies | MIT / Apache-2.0 / BSD-2-Clause | Compiled into the optional `simurg_core` extension (`rust/simurg-core`); list with `cargo tree` |
| [UCSC Genome Browser](https://genome.ucsc.edu/) `cytoBand` tables (hg38, hg19) and `ncbiRefSeq` (RefSeq transcripts, NCBI) | Free for all uses (UCSC data terms; RefSeq is public domain) | `simurg/cn/data/cytoBand_GRCh3{7,8}.txt.gz` and the derived centromere/acrocentric mask BEDs are shipped; `scripts/build_annotation_bundle.sh` downloads cytoBand + ncbiRefSeq for gene lists; `simurg/pgx/data/pgx_cn_regions.GRCh38.v1.bed` is derived from ncbiRefSeq exon coordinates (`scripts/pgx/build_cn_regions.py`) |
| [illuminaio](https://github.com/HenrikBengtsson/illuminaio) | GPL-2 | Documentation of IDAT field codes only; oracle via subprocess | | [illuminaio](https://github.com/HenrikBengtsson/illuminaio) | GPL-2 | Documentation of IDAT field codes only; oracle via subprocess |
| [CPIC](https://cpicpgx.org) database via the CPIC API (allele definitions, allele function, diplotype→phenotype tables) | CC0 1.0 | downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); `simurg pgx db build` derives the local PGx DB from it; source version and file hashes are recorded in the DB `meta` table |
| [PharmVar](https://www.pharmvar.org) haplotype definitions (Gaedigk et al. 2018 CPT 103:399) | PharmVar terms of use (attribution) | per-gene zips downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); `simurg pgx db build` derives core-allele definitions for genes CPIC does not define |
| [GeT-RM](https://www.cdc.gov/lab-quality/php/get-rm/reference-materials.html) consolidated PGx/HLA table (CDC) | US government work | validation truth only (`scripts/pgx/getrm_truth.py`), not redistributed |
| [ClinPGx](https://www.clinpgx.org) (formerly PharmGKB) clinical-annotation variants | CC BY-SA 4.0 (attribution: ClinPGx, https://www.clinpgx.org/page/citingClinpgx) | `clinicalVariants.zip` downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); rsIDs become genotype-only variant-based genes in the local PGx DB |
## BSD-2-Clause (BeadArrayFiles, verbatim from its README) ## BSD-2-Clause (BeadArrayFiles, verbatim from its README)
+5
View File
@@ -4,6 +4,11 @@ Plan §20 lists the open questions; this file records what the implementation ha
| ID | Date | Topic | Finding | Status | | ID | Date | Topic | Finding | Status |
|---|---|---|---|---| |---|---|---|---|---|
| K-030 | 2026-09-29 | ArrayTrain (v2) | The clustering is ours, not the vendor's (plan §12): unbounded 1/2/3-component θ mixtures chosen by BIC, with components labelled by HWE goodness of fit plus weak position and gap priors. Fixed θ windows (v1) mislabelled compressed and asymmetric loci. On GSE165845 (train on replicate 3, evaluate on replicates 1–2, chr22 vs 1000G 30x) the result is 99.44 % concordance, 1.30 % NRD and 99.61 % call rate, against 99.46 % / 1.23 % / 99.15 % with the vendor EGT. The parameters were tuned on this evaluation (a flat optimum). GenTrain and cluster-separation scores are ours, so GenCall scores are not comparable with vendor-file scores. | Open: second cohort/chip; score calibration (`docs/validation/V2_arraytrain.md`). |
| K-029 | 2026-09-29 | PGx star alleles (v2) | (1) PGX-012 error model: ε(GS) = 0.5·(1 − GS)² was replaced by ε = 1e-3 (GS ≥ 0.15) / 0.05 — after probe masking 0 of 9 740 GSA v4 PGx calls disagreed with 1000G 30x at any GS, and the old model made correct low-GS calls lose to the prior (spec amended). (2) PGX-010 masks derived from 14 individuals (spec: ≥ 100); a variant absent from the filtered 1000G panel counts as hom-ref only if rare (CYP2B6 rs2279343 is filtered out, not absent). (3) CPIC core-allele definitions use IUPAC wildcards; alleles without definitions (NUDT15 *2, structural CYP2D6) are not callable from SNVs. (4) GeT-RM uses legacy UGT1A1/SLCO1B1 names. Result vs GeT-RM (16 CEPH): 100/109 gene calls, CYP2D6/2C19/2C9 41/45. (5) DPWG: no public machine-readable genotype→phenotype mapping; CPIC mappings are used for genes CPIC covers (as ClinPGx does), DPWG-only genes (CYP1A2, CYP3A4, F5) get no phenotype. (6) GeT-RM definitions are older than CPIC/PharmVar for UGT1A1, SLCO1B1, CYP1A2 and CYP2C8 (e.g. PharmVar CYP2C8 *19 = rs2071426 alone). Result vs GeT-RM (16 CEPH, 21 genes): 154/168 comparable gene calls. Independent check on GSE165845 (GSA v1 C2, 120 individuals × 3, WGS-derived truth, 2-fold masks): 95.7 % array-resolvable concordance, 67.9 % top-solution (the chip lacks many defining variants), replicate concordance 99.2 % without the failed array. | Open: KNMP DPWG tables if they become available (`docs/validation/V2_pgx_star.md`). |
| K-028 | 2026-09-29 | PGx CN (v2) | Deviations from PCN-009: the per-probe reference is the training cohort's median stored in the model (a calling-batch median is not CN 2 where deletions are common, e.g. GSTM1 null ≈ 50 %); no GC-correction step; the CN mixture is ours: µ_c = δ + γ·log2((c+1)/3) per region with a free lower CN-0 component and Hardy–Weinberg-constrained weights (probe compression differs by region: γ ≈ 0.19 CYP2D6.p5 … 0.56 GSTT1). Interim validation on the GSA v4 PGx demo — whose 36 arrays turned out to be 30 CEPH/1000G individuals — against NYGC 30x SV RD_CN: 224/240 region calls (GSTM1, UGT2B17 30/30), but in-sample (trained on the same arrays, < 96 samples, priors chosen with this truth in view). | Open: validate on an independent GSA-PGx cohort; CYP2D6 via GeT-RM star alleles (`docs/validation/V2_pgx_cn.md`). |
| K-027 | 2026-09-29 | CN/LOH (v2) | CYTO-014 noise gate: the summary `logr_dev` also measures cluster-file mismatch (GSE165845: 0.246, all samples `SampleQuality`) while the LRR used after the batch reference has SD ≈ 0.1; the gate now uses the SD of the normalised LRR (the JSON still reports the summary value). Spec: built-in mask/cytobands from UCSC; the annotation bundle uses UCSC ncbiRefSeq instead of RefSeq GFF + Ensembl GTF + HGNC. Real-data result: constitutional DEL/DUP calls 74–77 % in NYGC truth, mosaic calls mostly false positives. Pi tuning: mosaic prior / switch distance barely matter; CYTO-041 (noise-normalised BAF split, complete-loss promotion) cuts PASS mosaic calls by ~30 % without losing implanted ≥ 15 % mosaics. Two limits remain: (1) without phasing, < 20 % mosaic losses are not separable from regional BAF noise; (2) common CNVs (carrier frequency > 20 %) are almost never detected (1/236 DEL) because the median-LRR batch reference absorbs them; rare DEL (< 5 %) 38 %. Final cloud run (360 arrays, CYTO-041/042 defaults): all samples `OK`; PASS precision DEL 44 %, DUP 57 % (61 % for CNV calls without one bad array); rare DEL sensitivity 30 %. | Open: rare-event sensitivity as primary metric; phasing-based mosaic BAF (`docs/validation/V2_cn.md`). |
| K-026 | 2026-09-29 | Methylation QC (v2) | Spec deviations: vendor EPIC manifests put a 5th field on some control lines (≥ 4 fields accepted); detection p follows sesame's pOOBAH (1 − max(F(M), F(U))) rather than the ambiguous "1 − ECDF(M + U)"; minfi-style `Sentrix_ID`/`Sentrix_Position` sheet keys accepted. Against sesame 1.28.1 on minfiDataEPIC: β r = 0.99998 (max \|Δβ\| 0.011), detection calls 99.1 % identical, noob signals within 0.5 % (median) / 5 % (max). The systematic remainder is unexplained; it is not the negative controls (sesame did not attach the supplied control table). | Open; `docs/validation/V2_methylation.md`. Next step: dump sesame's background vectors and noob parameters to find the difference. |
| K-025 | 2026-09-28 | Scaling limits (P9) | With the Rust kernels a Graviton3 core genotypes 78–80 GSA samples/min. At > 8 workers a 125 MB/s gp3 volume caps the run at ~620 samples/min (GTC ≈ 13 MB each); on a RAM disk efficiency is 0.77 at 16 and 0.48 at 32 workers for 360 samples, explained by a fixed start-up cost (≈ 2.5 s + 0.2 s per worker). Linear scaling for large batches is inferred from that model, not measured. | `docs/validation/P9.md`; measure a ≥ 5000-sample batch when one is at hand. | | K-025 | 2026-09-28 | Scaling limits (P9) | With the Rust kernels a Graviton3 core genotypes 78–80 GSA samples/min. At > 8 workers a 125 MB/s gp3 volume caps the run at ~620 samples/min (GTC ≈ 13 MB each); on a RAM disk efficiency is 0.77 at 16 and 0.48 at 32 workers for 360 samples, explained by a fixed start-up cost (≈ 2.5 s + 0.2 s per worker). Linear scaling for large batches is inferred from that model, not measured. | `docs/validation/P9.md`; measure a ≥ 5000-sample batch when one is at hand. |
| K-024 | 2026-09-28 | GSE165845 (P8) | GSA-MD v1 chips have no public cluster file; the GSA v1 C1 EGT with the C2 build-38 BPM covers 99.996 % of bead addresses and gives median LogR deviation 0.246 (cluster mismatch, not noise). GSM5051309 (NA20362 rep 2) is a failed array (call rate 0.762, concordance 0.336 with its own replicates). 27 510 array records have no NYGC 30x record and are scored as hom-ref; 1444 of them are systematically called alt. | Recorded in `docs/validation/P8.md`; results reported with and without these. | | K-024 | 2026-09-28 | GSE165845 (P8) | GSA-MD v1 chips have no public cluster file; the GSA v1 C1 EGT with the C2 build-38 BPM covers 99.996 % of bead addresses and gives median LogR deviation 0.246 (cluster mismatch, not noise). GSM5051309 (NA20362 rep 2) is a failed array (call rate 0.762, concordance 0.336 with its own replicates). 27 510 array records have no NYGC 30x record and are scored as hom-ref; 1444 of them are systematically called alt. | Recorded in `docs/validation/P8.md`; results reported with and without these. |
| K-023 | 2026-09-28 | P6 optional outputs | Implemented and validated the same day: `--info-fields` (with `--egt`), `--extra-format`, `--merge-snv-indel`, `--auxiliary-loci`, PLINK 2 `.pgen`. Two decisions: OUT-035 rule (d) uses the MNV's last plus-strand base for minus-strand designs (spec amended); `--extra-format` writes NORMX/NORMY missing where the raw intensities are both 0, and THETA/R missing where both normalized intensities are 0 (148 values on 3 GSA samples; gtc2vcf writes 0). | resolved | | K-023 | 2026-09-28 | P6 optional outputs | Implemented and validated the same day: `--info-fields` (with `--egt`), `--extra-format`, `--merge-snv-indel`, `--auxiliary-loci`, PLINK 2 `.pgen`. Two decisions: OUT-035 rule (d) uses the MNV's last plus-strand base for minus-strand designs (spec amended); `--extra-format` writes NORMX/NORMY missing where the raw intensities are both 0, and THETA/R missing where both normalized intensities are 0 (148 values on 3 GSA samples; gtc2vcf writes 0). | resolved |
+37 -1
View File
@@ -8,6 +8,10 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_BPM_CSV_MISMATCH` | error | BPM and CSV manifests describe different loci | IN-042 | | `E_BPM_CSV_MISMATCH` | error | BPM and CSV manifests describe different loci | IN-042 |
| `E_BPM_FORMAT` | error | BPM manifest is malformed or unsupported | IN-040 | | `E_BPM_FORMAT` | error | BPM manifest is malformed or unsupported | IN-040 |
| `E_BUILD_MISMATCH` | error | Manifest genome build differs from the FASTA build | IN-063 | | `E_BUILD_MISMATCH` | error | Manifest genome build differs from the FASTA build | IN-063 |
| `E_CN_FAILED` | error | CN calling failed for the sample | CYTO-040 |
| `E_CN_SEX_UNRESOLVED` | error | Sample sex unresolved and --unknown-sex-policy error | CYTO-024 |
| `E_CN_SIDECAR_MISSING` | error | cn_call_manifest.json not found in the cn call output | CYTO-032 |
| `E_CN_VCF_UNINDEXED` | error | CNV VCFs must be bgzipped and indexed for annotate | CYTO-032 |
| `E_CONFIG_KEY` | error | Unknown key in --config YAML | CLI-019 | | `E_CONFIG_KEY` | error | Unknown key in --config YAML | CLI-019 |
| `E_CONFIG_PARSE` | error | --config file is not valid YAML | CLI-019 | | `E_CONFIG_PARSE` | error | --config file is not valid YAML | CLI-019 |
| `E_CSV_MANIFEST_FORMAT` | error | CSV manifest is malformed | IN-041 | | `E_CSV_MANIFEST_FORMAT` | error | CSV manifest is malformed | IN-041 |
@@ -26,9 +30,27 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_IDAT_MANIFEST_MISMATCH` | error | IDATs and manifest belong to different products | IN-027 | | `E_IDAT_MANIFEST_MISMATCH` | error | IDATs and manifest belong to different products | IN-027 |
| `E_IDAT_PAIR_MISMATCH` | error | Red and Grn IDATs of a sample do not match | IN-023 | | `E_IDAT_PAIR_MISMATCH` | error | Red and Grn IDATs of a sample do not match | IN-023 |
| `E_INTERNAL` | error | Internal error (bug); rerun with --debug and report | CLI-014 | | `E_INTERNAL` | error | Internal error (bug); rerun with --debug and report | CLI-014 |
| `E_METH_CONTROLS_FORMAT` | error | Controls not formatted correctly. Must have 4 columns (Address,Type,Color_Channel,Name) | MET-002 |
| `E_METH_MISSING_COLUMNS` | error | Missing required columns in the methylation manifest | MET-002 |
| `E_METH_NO_ASSAY` | error | Probe section not found | MET-002 |
| `E_METH_NO_CONTROLS` | error | Missing control probes in manifest | MET-002 |
| `E_METH_NO_IDATS` | error | No IDATs found | MET-004 |
| `E_METH_PREP_FAILED` | error | Prep failed (sample) | MET-010 |
| `E_MODEL_CLUSTER_MISMATCH` | error | Input GTCs were called with another cluster file than the model | IN-050 |
| `E_MODEL_MANIFEST_MISMATCH` | error | Model was built for a different manifest probe set | IN-051 |
| `E_MODEL_VERSION` | error | Model schema is too old; rebuild it | IN-051 |
| `E_NO_INPUT` | error | Neither an input folder nor a sample sheet was given | CLI-006 | | `E_NO_INPUT` | error | Neither an input folder nor a sample sheet was given | CLI-006 |
| `E_NO_INPUT_FOLDER` | error | Sample sheet has no RootFolder and no folder was given | CLI-006 | | `E_NO_INPUT_FOLDER` | error | Sample sheet has no RootFolder and no folder was given | CLI-006 |
| `E_OUTPUT_EXISTS` | error | Outputs already exist; use --force or --resume | CLI-010 | | `E_OUTPUT_EXISTS` | error | Outputs already exist; use --force or --resume | CLI-010 |
| `E_PGX_BATCH_TOO_SMALL` | error | Too few (passing) samples in the batch for PGx CN calling | PCN-004 |
| `E_PGX_CN_FAILED` | error | PGx CN calling failed for the sample | PCN-002 |
| `E_PGX_CN_MISSING` | error | A sample has no PGx CN VCF (give --no-cn to run without) | PGX-011 |
| `E_PGX_GUIDELINE` | error | Guideline tables not present in the PGx DB | PGX-025 |
| `E_PGX_PLATFORM_UNKNOWN` | error | PGx CN platform (LCG/EX) unknown: give --csv or --platform | CLI-032 |
| `E_PGX_SOURCE` | error | PGx source tables missing; run scripts/pgx/fetch_sources.sh | PGX-001 |
| `E_PGX_TRAIN_TOO_SMALL` | error | Too few (passing) samples to train a PGx CN model | PCN-005 |
| `E_PGX_VCF_UNINDEXED` | error | SNV VCFs for PGx must be bgzipped and indexed | PGX-011 |
| `E_PGX_VCF_UNSUITABLE` | error | SNV VCF unsuitable for PGx (squashing/locus filters/foreign) | PGX-011 |
| `E_QC_CONFIG_EXT` | error | QC config file must be .yaml or .yml | QC-021 | | `E_QC_CONFIG_EXT` | error | QC config file must be .yaml or .yml | QC-021 |
| `E_QC_CONFIG_MISSING` | error | QC config file does not exist | QC-021 | | `E_QC_CONFIG_MISSING` | error | QC config file does not exist | QC-021 |
| `E_QC_CONFIG_PARSE` | error | QC config file is not valid YAML | QC-021 | | `E_QC_CONFIG_PARSE` | error | QC config file is not valid YAML | QC-021 |
@@ -37,7 +59,6 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_QC_FORMAT` | error | Invalid --output-format value | QC-063 | | `E_QC_FORMAT` | error | Invalid --output-format value | QC-063 |
| `E_QC_LABEL_COUNT` | error | Number of labels does not match number of datasets | QC-042 | | `E_QC_LABEL_COUNT` | error | Number of labels does not match number of datasets | QC-042 |
| `E_QC_LABEL_DUPLICATE` | error | Dataset labels are not unique | QC-042 | | `E_QC_LABEL_DUPLICATE` | error | Dataset labels are not unique | QC-042 |
| `E_QC_METHYLATION` | error | Methylation control metrics are not available yet (v2) | QC-006 |
| `E_QC_MIXED_TYPES` | error | Genotyping and methylation datasets cannot be mixed | QC-043 | | `E_QC_MIXED_TYPES` | error | Genotyping and methylation datasets cannot be mixed | QC-043 |
| `E_QC_NO_DATASETS` | error | No QC dataset folder found | QC-041 | | `E_QC_NO_DATASETS` | error | No QC dataset folder found | QC-041 |
| `E_QC_PROFILE` | error | Unknown QC profile | QC-024 | | `E_QC_PROFILE` | error | Unknown QC profile | QC-024 |
@@ -54,7 +75,12 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_SHEET_NO_KEY_COLUMNS` | error | Sample sheet lacks SentrixBarcode_A/SentrixPosition_A | IN-002 | | `E_SHEET_NO_KEY_COLUMNS` | error | Sample sheet lacks SentrixBarcode_A/SentrixPosition_A | IN-002 |
| `E_SHEET_RESERVED` | error | Sample sheet column collides with an output column | IN-005 | | `E_SHEET_RESERVED` | error | Sample sheet column collides with an output column | IN-005 |
| `E_SHEET_TOO_LONG` | error | Sample sheet name or value longer than 500 characters | IN-006 | | `E_SHEET_TOO_LONG` | error | Sample sheet name or value longer than 500 characters | IN-006 |
| `E_TRAIN_TOO_SMALL` | error | Too few GTCs to train a cluster file | plan §12 |
| `I_CN_SEX_RESOLVED` | info | Sample sex updated from U by the caller | CYTO-024 |
| `I_UNLISTED_IDATS` | info | IDAT pairs not listed in the sample sheet are ignored | IN-016 | | `I_UNLISTED_IDATS` | info | IDAT pairs not listed in the sample sheet are ignored | IN-016 |
| `W_CN_NO_FASTA` | warning | No FASTA: VCF REF padding bases written as N | CYTO-029 |
| `W_CN_PROBE_FLOOR` | warning | Minimum probe count below 10 raised to 10 | CYTO-008 |
| `W_CN_THRESHOLD_EXCEEDS_GENOME` | warning | Size threshold exceeds the effective genome; no events | CYTO-009 |
| `W_CTRL_DEFS_DIFFER` | warning | BPM and CSV control definitions differ; the BPM wins | QC-001 | | `W_CTRL_DEFS_DIFFER` | warning | BPM and CSV control definitions differ; the BPM wins | QC-001 |
| `W_CTRL_MISSING` | warning | Controls needed by a metric are absent; metric not evaluable | QC-007 | | `W_CTRL_MISSING` | warning | Controls needed by a metric are absent; metric not evaluable | QC-007 |
| `W_CTRL_ZERO_DIVISOR` | warning | Control metric has a zero denominator (NaN) | QC-008 | | `W_CTRL_ZERO_DIVISOR` | warning | Control metric has a zero denominator (NaN) | QC-008 |
@@ -73,7 +99,17 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `W_MERGE_ALL_NC` | warning | Every probe at a merged site is a no-call | OUT-031 | | `W_MERGE_ALL_NC` | warning | Every probe at a merged site is a no-call | OUT-031 |
| `W_MERGE_AMBIGUOUS` | warning | Probes at a site allow several genotypes; set missing | OUT-031 | | `W_MERGE_AMBIGUOUS` | warning | Probes at a site allow several genotypes; set missing | OUT-031 |
| `W_MERGE_INCONSISTENT` | warning | Probes at a site disagree; genotype set missing | OUT-031 | | `W_MERGE_INCONSISTENT` | warning | Probes at a site disagree; genotype set missing | OUT-031 |
| `W_METH_MISSING_CONTROL_NAMES` | warning | Control names missing from the manifest; metric set to NA | MET-009 |
| `W_METH_MISSING_CONTROL_TYPES` | warning | Control types missing from the manifest; metric set to NA | MET-009 |
| `W_METH_MISSING_SAMPLE` | warning | Missing sample: IDATs absent; the sample fails, the run continues | MET-015 |
| `W_METH_NO_FEATURES` | warning | No features, skipping PCA plot | MET-013 |
| `W_MODEL_CLUSTER_MISMATCH` | warning | Input GTCs were called with another cluster file than the model | IN-050 |
| `W_NO_BGZIP` | warning | Uncompressed VCF written; downstream commands need bgzip + index | OUT-010 | | `W_NO_BGZIP` | warning | Uncompressed VCF written; downstream commands need bgzip + index | OUT-010 |
| `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_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_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 |
| `W_QC_PEDIGREE` | warning | Parent1/Parent2/Replicate value does not name a sample in the set | IN-018 | | `W_QC_PEDIGREE` | warning | Parent1/Parent2/Replicate value does not name a sample in the set | IN-018 |
+125 -7
View File
@@ -1,4 +1,4 @@
# SimurgArray user guide (v1.1) # SimurgArray user guide (v2.0)
SimurgArray turns Illumina Infinium genotyping BeadChip scans (IDAT files) into genotype calls, QC tables and SimurgArray turns Illumina Infinium genotyping BeadChip scans (IDAT files) into genotype calls, QC tables and
reports, VCF/BCF and PLINK files. It is a personal, non-commercial **research** tool — not for clinical or reports, VCF/BCF and PLINK files. It is a personal, non-commercial **research** tool — not for clinical or
@@ -6,6 +6,8 @@ diagnostic use. Behaviour is specified item by item in `SIMURGARRAY_FUNCTIONAL_S
cited below); the algorithms and their sources are in `SIMURGARRAY_MASTER_PLAN.md`. cited below); the algorithms and their sources are in `SIMURGARRAY_MASTER_PLAN.md`.
Contents: [Install](#install) · [Inputs](#inputs) · [A complete run](#a-complete-run) · [Commands](#commands) · Contents: [Install](#install) · [Inputs](#inputs) · [A complete run](#a-complete-run) · [Commands](#commands) ·
[Methylation](#methylation-arrays) · [CN/LOH](#copy-number-loh-and-mosaicism) · [PGx](#pharmacogenomics) ·
[Cluster training](#cluster-training-arraytrain) ·
[Presets and reproducibility](#presets-and-reproducibility) · [QC](#quality-control) · [Outputs](#outputs) · [Presets and reproducibility](#presets-and-reproducibility) · [QC](#quality-control) · [Outputs](#outputs) ·
[Accuracy](#accuracy) · [Limitations](#limitations) · [Troubleshooting](#troubleshooting) [Accuracy](#accuracy) · [Limitations](#limitations) · [Troubleshooting](#troubleshooting)
@@ -33,15 +35,15 @@ Without them the same code runs in pure Python/NumPy; `SIMURG_PURE_PYTHON=1` for
`manylinux_2_28` kernel wheels for x86_64 and aarch64, glibc ≥ 2.28): `manylinux_2_28` kernel wheels for x86_64 and aarch64, glibc ≥ 2.28):
```bash ```bash
python3 -m venv simurg-env && simurg-env/bin/pip install simurg-1.1.0-py3-none-any.whl \ python3 -m venv simurg-env && simurg-env/bin/pip install simurg-2.0.0-py3-none-any.whl \
simurg_core-1.1.0-cp312-abi3-manylinux_2_28_$(uname -m).whl # the second wheel is optional simurg_core-1.1.0-cp312-abi3-manylinux_2_28_$(uname -m).whl # the second wheel is optional
``` ```
**Docker** (image ≈ 550 MB, kernels included, runs as a non-root user; data stay in the mounted folder): **Docker** (image ≈ 550 MB, kernels included, runs as a non-root user; data stay in the mounted folder):
```bash ```bash
docker build -t simurg:1.1.0 . docker build -t simurg:2.0.0 .
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/work simurg:1.1.0 \ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/work simurg:2.0.0 \
genotype --bpm M.bpm --egt C.egt --idats idats --out run/gtc genotype --bpm M.bpm --egt C.egt --idats idats --out run/gtc
``` ```
@@ -108,11 +110,122 @@ simurg export finalreport --bpm M.bpm --csv M.csv --egt C.egt --gtcs run/gtc --o
| `simurg qc suggest-thresholds` | data-driven threshold YAML from a cohort's `controls_qc.tsv` | | `simurg qc suggest-thresholds` | data-driven threshold YAML from a cohort's `controls_qc.tsv` |
| `simurg gtc-to-vcf` | harmonised VCF/BCF. Options: `--merge`, `--unsquash-duplicates`, `--merge-snv-indel`, `--auxiliary-loci`, `--info-fields`, `--extra-format`, `--filter-gs`, `--drop-ambiguous`, `--ploidy-policy sex-aware\|diploid`, `--include-loci/--exclude-loci` | | `simurg gtc-to-vcf` | harmonised VCF/BCF. Options: `--merge`, `--unsquash-duplicates`, `--merge-snv-indel`, `--auxiliary-loci`, `--info-fields`, `--extra-format`, `--filter-gs`, `--drop-ambiguous`, `--ploidy-policy sex-aware\|diploid`, `--include-loci/--exclude-loci` |
| `simurg export plink\|bedgraph\|finalreport\|locus-summary` | downstream formats | | `simurg export plink\|bedgraph\|finalreport\|locus-summary` | downstream formats |
| `simurg methyl qc` / `methyl convert-manifest` | methylation-array control QC, detection p-values, β/M values (see below) |
| `simurg cn build-model\|call\|annotate` | genome-wide CNV / LOH / mosaic calling from GTCs (see below) |
| `simurg pgx db build\|coverage`, `pgx copy-number train\|call`, `pgx star-allele call`, `pgx phenotype` | pharmacogenomics (see below) |
| `simurg train` | ArrayTrain: an EGT cluster file from a cohort's GTCs (see below) |
| `simurg compare A B` | field-level differential test of GTCs or VCFs (files or folders paired by name): EXACT / NEAR / DISCREPANCY / UNKNOWN with an HTML dashboard; `--fail-on-discrepancy` exits 3 | | `simurg compare A B` | field-level differential test of GTCs or VCFs (files or folders paired by name): EXACT / NEAR / DISCREPANCY / UNKNOWN with an HTML dashboard; `--fail-on-discrepancy` exits 3 |
Global logging options on every command: `--quiet`, `--log-level`, `--log-json` (JSON Lines on stderr), `--debug`. Global logging options on every command: `--quiet`, `--log-level`, `--log-json` (JSON Lines on stderr), `--debug`.
Run `simurg COMMAND --help` for the full list. Run `simurg COMMAND --help` for the full list.
## Methylation arrays
`simurg methyl qc` runs control-probe QC and a signal summary for Infinium methylation arrays (EPIC v1/v2, MSA,
custom designs) from IDATs and the methylation CSV manifest (spec MET-001 … MET-018). It is a QC tool: there is no
differential-methylation analysis.
```bash
simurg methyl qc --manifest infinium-methylationepic-v-1-0-b5-manifest-file.csv --idats idats/ \
--sheet SampleSheet.csv --out run/methyl --profile auto
```
* 21 control metrics (staining, extension, hybridization, target removal I/II, bisulfite conversion I/II with their
background ratios, specificity I/II, non-polymorphic, restoration); thresholds from the `methylation_epic` /
`methylation_msa` profiles (`auto` picks by manifest name), `--config` or `--threshold KEY=VALUE`.
* Per sample: detection p-values (pOOBAH: out-of-band Infinium I signal and negative controls), noob background
correction, dye-bias correction (`--dye-bias linear|nonlinear`), β = M/(M+U+α) (`--beta-offset`, default 0) and
M-values.
* `pass_qc` = every enabled, evaluable metric passes; `--min-prop-passing X` also requires that fraction of probes
to pass detection (`--pval-threshold`, default 0.05).
* Outputs: `methyl_qc_summary.tsv/.xlsx/.html` (detection histogram and a β PCA coloured by `Sample_Group`),
`samples/<id>/<id>_cgs.tsv.gz` (probe_id, beta, m_value, detection_p), `<id>_ctrl.tsv.gz`, `pcs.tsv.gz`,
`probes.tsv`/`controls.tsv` (the manifest in the open-source toolkits' layout; also written by
`simurg methyl convert-manifest`), and `controls_raw.tsv`/`controls_qc.tsv`, so `simurg qc report` accepts the folder.
* Sample sheets may use `Sentrix_ID`/`Sentrix_Position` (minfi/sesame convention) as key columns.
* `simurg qc controls --array-type methylation --csv MANIFEST.csv` computes only the control tables.
## Copy number, LOH and mosaicism
Genome-wide CNV / LOH / mosaic calling from GTCs (spec CYTO-001 … CYTO-042; validation in
`docs/validation/V2_cn.md`).
```bash
# once per manifest + batch: GC covariates, population BAF, and a batch reference from ≥ 24 normal GTCs
simurg cn build-model --bpm GSA.bpm --fasta GRCh38.fa --egt GSA.egt --reference-gtcs normals/ --out gsa.simurg-cyto
simurg cn call --bpm GSA.bpm --model gsa.simurg-cyto --gtcs run/ --fasta GRCh38.fa --out run/cn
simurg cn annotate --vcfs run/cn --annotation-bundle simurg-cyto-annot-GRCh38-….zip # ISCN, cytobands, genes
```
* Per sample `<id>.cyto.cnv.vcf.gz` (+ `.tbi`) with DEL/DUP/LOH/mosaic calls, `<id>.cn_segments.parquet`, and
`cn_sample_summary.tsv` / `cn_call_manifest.json` for the batch. `scripts/build_annotation_bundle.sh` builds the
annotation bundle from UCSC tables.
* Mosaic fractions are estimated from the noise-normalised BAF split (CYTO-041); losses without heterozygotes are
promoted to constitutional deletions. Per-probe LRR noise from the reference is on by default
(`--no-probe-lrr-sd` turns it off).
* Array CN calling finds rare events; CNVs common in the reference batch are normalised away (K-027).
## Pharmacogenomics
Targeted copy number, star alleles and phenotypes (spec PCN-001 … PCN-017, PGX-001 … PGX-035; validation in
`docs/validation/V2_pgx_cn.md` and `V2_pgx_star.md`). GRCh38 only.
```bash
bash scripts/pgx/fetch_sources.sh # CPIC (CC0), PharmVar, ClinPGx → data/pgx/sources/
simurg pgx db build --sources data/pgx/sources --fasta GRCh38.fa --manifests GSA-PGx.bpm --csv GSA-PGx.csv \
[--mask GSA-PGx.mask.tsv] --out pgxdb/ # → pgx_db-1-<sha12>.sqlite
simurg pgx coverage --bpm GSA-PGx.bpm --db pgxdb/pgx_db-….sqlite # which alleles this array can call
simurg pgx copy-number train --gtcs cohort/ --bpm GSA-PGx.bpm --csv GSA-PGx.csv --fasta GRCh38.fa --out cnmodel/
simurg pgx copy-number call --gtcs run/ --model cnmodel/GSA-PGx.simurg-pgxcn --fasta GRCh38.fa --out run/vcf
simurg gtc-to-vcf --gtcs run/ --bpm GSA-PGx.bpm --csv GSA-PGx.csv --fasta GRCh38.fa --out run/vcf
simurg pgx star-allele call --vcfs run/vcf --db pgxdb/pgx_db-….sqlite --out run/pgx
simurg pgx phenotype --calls run/pgx --db pgxdb/pgx_db-….sqlite [--guideline cpic|dpwg]
```
* **Targeted CN** for CYP2D6 (5′ region, intron 2, exon 9), CYP2A6, GSTM1, GSTT1 (alt contig), UGT2B17, SULT1A1 and
CYP2E1: one record per region in `<id>.pgx_cn.vcf.gz`. Training needs ≥ 96 samples (`--min-samples` lowers it
for research use) and the platform from `--csv` or `--platform`.
* **Star alleles**: CPIC star-allele genes, PharmVar genes CPIC does not define (CYP1A2, CYP2A6, CYP2A13, CYP2C8,
CYP3A4), G6PD, HLA-A/B tag variants, genotype-only variant-based genes (CPIC and ClinPGx), and `*0` genotypes
for GSTM1/GSTT1/UGT2B17. CYP2D6 structures (`*5`, `xN`, `*68+*4`, `*36+*10`, `*13`) come from the sub-region CNs.
* **Probe mask** (PGX-010): `scripts/pgx/derive_masks.py` compares array calls with 1000 Genomes 30x for the batch's
1000G individuals and masks probes below 95 % concordance.
* **Outputs**: `diplotype_summary.tsv`, `supporting_variants.tsv`, `unresolved_variants.tsv`,
`variants_not_covered.tsv`, `pgx_calls.json`; after `phenotype`, `<id>.pgx.json` (`simurg.pgx/1`) and
`diplotype_annotations.tsv`. Array-equivalent alleles, no-call impact and alternatives are listed per gene.
* **DPWG**: genes CPIC covers use the CPIC mappings (as ClinPGx does); DPWG-only genes (CYP1A2, CYP3A4, F5) get no
phenotype (K-029).
## Cluster training (ArrayTrain)
`simurg train` builds an EGT cluster file (v3, data block v9) from a cohort's GTCs, so calling does not depend on a
vendor cluster file (plan §12; validation in `docs/validation/V2_arraytrain.md`).
```bash
simurg genotype --bpm GSA.bpm --egt any.egt --idats cohort_idats/ --out cohort_gtc # any EGT: training reads raw X/Y
simurg train --gtcs cohort_gtc --bpm GSA.bpm --out mycohort.egt # ≥ 100 GTCs (--min-samples)
simurg genotype --bpm GSA.bpm --egt mycohort.egt --idats new_idats/ --out run/ # call with the trained file
```
* Training uses each GTC's raw intensities and normalisation transforms, not its genotypes. The cluster file the
cohort was first called with therefore does not matter.
* Samples are chosen by sex: X loci are trained from females, Y from males, and MT is haploid. The sex is the GTC's
`gender` field.
* At most `--max-samples` (2000) GTCs are used, as a seeded random subset.
* **Per locus:**
1. 1-, 2- and 3-cluster θ mixtures are fitted; BIC picks one.
2. Clusters are labelled AA/AB/BB by Hardy–Weinberg fit plus weak position priors, so compressed or asymmetric
loci are handled.
3. Missing clusters are imputed from relations learned across well-clustered loci of the same assay type.
* **Outputs:** `<out>.egt` and `<out>.egt.json`. The JSON gives the counts of 3-, 2- and 1-cluster loci and the
median training score.
* GenTrain and cluster-separation scores in a trained file are SimurgArray's own. GenCall scores are therefore not
comparable with calls made with a vendor cluster file, so re-derive any GenCall-score thresholds (K-030).
* **Accuracy.** GSE165845, trained on 120 arrays and evaluated on 240 other arrays of the same individuals, chr22
against 1000G 30x. The trained file gives 99.44 % concordance at a 99.61 % call rate; the vendor file gives
99.46 % at 99.15 %.
* **Time.** 120 GSA samples take about 70 min on a Raspberry Pi 5, with a 2 GB peak.
## Presets and reproducibility ## Presets and reproducibility
* `--preset current` (default): normalization 1.2.0, sex rule 2, GTC v5 — the current vendor software behaviour. * `--preset current` (default): normalization 1.2.0, sex rule 2, GTC v5 — the current vendor software behaviour.
@@ -173,19 +286,24 @@ Validated phase by phase (reports in `docs/validation/`):
| Pedigree QC, 2141 Omni2.5 samples | 717/717 parent–offspring pairs; trio Mendel error median 0.031 % | | Pedigree QC, 2141 Omni2.5 samples | 717/717 parent–offspring pairs; trio Mendel error median 0.031 % |
| 360 GSA arrays (GSE165845) vs NYGC 30x WGS | genotype concordance 0.9934; replicate concordance 0.9966 | | 360 GSA arrays (GSE165845) vs NYGC 30x WGS | genotype concordance 0.9934; replicate concordance 0.9966 |
| NA12878 × 3 vs GIAB HG001 v4.2.1 | concordance 0.9985; non-reference F1 0.997 | | NA12878 × 3 vs GIAB HG001 v4.2.1 | concordance 0.9985; non-reference F1 0.997 |
| Methylation QC vs sesame (minfiDataEPIC) | β r = 0.99998 |
| CN, 360 GSE165845 arrays vs NYGC 30x SV | PASS precision DEL 44 % / DUP 57 %; rare-DEL sensitivity 30 % |
| PGx, GSA v4 demo vs GeT-RM (16 CEPH) | 154/168 gene calls; GSE165845 array-resolvable 95.7 % |
| ArrayTrain, GSE165845 chr22 vs 1000G 30x | 99.44 % concordance (vendor EGT 99.46 %) at 99.61 % call rate |
A nightly differential run (`scripts/nightly.py`) re-checks these against the oracles. A nightly differential run (`scripts/nightly.py`) re-checks these against the oracles.
## Limitations ## Limitations
* Calls need an existing EGT cluster file; cluster training (v2), copy number / LOH / mosaicism and PGx * ArrayTrain was validated on one chip and cohort (GSA v1, GSE165845). CN calling finds rare events, but common
diplotyping are not in v1.0. CNVs are normalised away, and mosaic losses below ~20 % are not separable from noise (K-027). PGx is limited
to what the array's probes can resolve: `simurg pgx coverage` lists it. All v2 modules are research-grade.
* The largest cohort tested is 2141 samples (plan goal ≥ 10 000). Memory grows with samples × loci in the cohort * The largest cohort tested is 2141 samples (plan goal ≥ 10 000). Memory grows with samples × loci in the cohort
QC steps; on an 8 GB machine keep `--threads` low (≈ 2–3 GB per worker for GSA/Omni-size arrays). QC steps; on an 8 GB machine keep `--threads` low (≈ 2–3 GB per worker for GSA/Omni-size arrays).
* Speed: with the compiled kernels a GSA sample takes ~1.1 s on a Raspberry Pi 5 core (pure Python ~3.4 s); see * Speed: with the compiled kernels a GSA sample takes ~1.1 s on a Raspberry Pi 5 core (pure Python ~3.4 s); see
`docs/validation/P9.md` for server numbers and thread scaling. Loading a manifest/cluster file is a fixed cost `docs/validation/P9.md` for server numbers and thread scaling. Loading a manifest/cluster file is a fixed cost
per run (GSA ~5 s, Omni2.5 ~15 s on the Pi). per run (GSA ~5 s, Omni2.5 ~15 s on the Pi).
* Methylation arrays: control-probe QC only. * Methylation arrays: control-probe QC, detection and β values only (no differential analysis).
## Troubleshooting ## Troubleshooting
+86
View File
@@ -0,0 +1,86 @@
# V2 — ArrayTrain (plan §12)
`simurg train` builds an EGT cluster file from a cohort's GTCs. It does not reproduce the vendor's clustering, so the
benchmark is genotype accuracy against sequencing truth: an EGT trained by SimurgArray versus the vendor's published
cluster file, used by the same GenCall emulation on the same arrays.
## Set-up
- **Data.** GSE165845: 120 1000 Genomes individuals, each on 3 GSA v1 arrays. The C2 build-38 BPM is used; the
public GSA v1 C1 cluster file is the vendor baseline (K-024).
- **Training.** Replicate 3, 120 GTCs (59 F, 60 M, 1 U), all 618 540 loci. Pi 5, 69 min, peak 1.9 GB.
- **Evaluation.** Replicates 1–2 (240 arrays) go from IDAT through `simurg genotype` with the trained EGT, then
`simurg gtc-to-vcf`. The same arrays are also genotyped with the vendor EGT.
- **Truth.** The NYGC 30x phased 1000G panel on chr22: 8278 array SNV sites (`scripts/train/concordance_chr.py`).
Concordance counts called genotypes only. NRD is the non-reference discordance.
The evaluation individuals are also in the training set, but on different arrays. The cluster file never sees their
truth; the same holds for the vendor file.
## Results (240 arrays, chr22)
| Cluster file | Call rate | Concordance | NRD |
|---|---|---|---|
| ArrayTrain v1 (fixed θ windows) | 99.47 % | 99.27 % | 1.89 % |
| **ArrayTrain v2 (BIC + HWE labelling)** | **99.61 %** | **99.44 %** | **1.30 %** |
| Vendor GSA v1 C1 EGT | 99.15 % | 99.46 % | 1.23 % |
`V2_arraytrain_gse165845.json` holds the numbers. v2 calls 0.46 % more genotypes than the vendor file at nearly the
same concordance (−0.02 points).
## What changed from v1, and why
The v1 training fitted one three-component θ mixture. Its means were bounded to fixed windows (AA ≤ 0.4,
0.2 ≤ AB ≤ 0.8, BB ≥ 0.6), and close clusters were merged by a fixed θ gap.
A per-locus diagnosis on chr22 compared our trained clusters, the vendor clusters and the truth for 80 arrays:
- **Errors concentrate in few loci.** The top 20 loci held 43 % of the errors.
- **2-cluster loci were the problem.** There, v1 made 1418 errors against the vendor's 186.
- **The cause was the fixed θ windows.** Many loci are compressed or asymmetric in θ; for example the vendor file
has AA 0.05 / AB 0.27 / BB 0.45, or AA 0.37 / AB 0.68 / BB 0.96. With fixed windows such loci were mislabelled:
rs2003814 (truth 14 AB + 66 BB) was clustered as AA + AB.
v2 (`src/simurg/train/clusters.py`) replaces this with:
1. **Unbounded 1-, 2- and 3-component mixtures** on θ, plus a uniform outlier component. Each is fitted by EM from
several starts: canonical positions and θ quantiles.
- An SD floor of 0.02 inside the EM stops a component collapsing onto the θ = 0 / 1 spikes.
- BIC chooses among admissible fits. A fit is admissible if every component has ≥ max(3, 1 %) of the samples and
adjacent components are ≥ 1.5 standardised gaps apart.
2. **Labelling of the θ-ordered components** by the best non-decreasing map onto AA/AB/BB. A map's score adds up:
- the HWE goodness of fit of the implied genotype counts, with an inbreeding allowance F = 0.1. This is the fit
relative to the saturated multinomial, so monomorphic labellings are not favoured automatically;
- weak priors on the cluster positions: AA ~ N(0.03, 0.2), AB ~ N(0.5, 0.25), BB ~ N(0.97, 0.2);
- a weak prior on the θ gaps between genotype groups: 0.42 ± 0.12 per step.
Adjacent components may share a genotype (a split cluster) only when their standardised gap is < 3.5.
Fast chr22 loop (train on replicate 3, call 80 evaluation arrays, score against truth):
| Step | Errors (80 arrays) |
|---|---|
| v1 | 2562 |
| Unbounded mixtures + HWE labelling of 2-cluster fits | 1793 |
| + MIN_SEP 1.5 and position prior | 1611 |
| + general labelling with split-cluster merges (HWE goodness of fit) | 1380 |
| Vendor EGT | 1237 |
- **Parameter sweep.** MERGE_SEP {2.5, 3.5, 5} × HWE F {0.05, 0.1, 0.2} × gap SD {0.12, 0.2}. Every setting
lands at 99.70–99.79 % accuracy; MERGE_SEP 5 is clearly worse. The chosen values are in the middle of a flat
optimum.
- **Where the errors are now.** By our cluster count, v2's errors are 123 at 1-cluster loci (vendor 157), 217 at
2-cluster loci (vendor 113) and 1040 at 3-cluster loci (vendor 967).
- **Many 3-cluster errors are shared with the vendor.** Both files see three clear clusters where the truth is
monomorphic, e.g. rs191067607 and rs5758036. These are probe or truth problems, not clustering errors.
## Limits (K-030)
- Only one cohort and one chromosome were used for accuracy. The tuning used this evaluation, so the numbers are
optimistic for other chips and cohorts.
- 120 training arrays is close to the 100-sample minimum. Rare-allele clusters are imputed from cross-locus
relations, not observed.
- The trained EGT scores (GenTrain, cluster separation) are ours. GenCall scores from a trained file are not
comparable with scores from vendor files. At a matched call rate, GenCall score ranks v1's errors worse than the
vendor's.
- Training takes 69 min for 120 × 618 540 on the Pi (8 EM runs per locus); a Rust kernel would help at cohort scale.
@@ -0,0 +1,28 @@
{
"dataset": "GSE165845 GSA v1 (C2 build-38 BPM); train = replicate 3 (120 arrays), evaluate = replicates 1-2 (240 arrays)",
"truth": "1000G NYGC 30x phased panel, chr22 SNVs on the array",
"egt_v1_fixed_windows": {
"chrom": "22",
"samples": 240,
"truth_sites": 8278,
"call_rate": 0.9947053434807118,
"concordance": 0.9926765907658587,
"nrd": 0.018946074088334416
},
"egt_v2_bic_hwe": {
"chrom": "22",
"samples": 240,
"truth_sites": 8278,
"call_rate": 0.9960623540307643,
"concordance": 0.9943676222304063,
"nrd": 0.013034595450012981
},
"vendor_gsa_v1_c1_egt": {
"chrom": "22",
"samples": 240,
"truth_sites": 8278,
"call_rate": 0.9915201940887493,
"concordance": 0.9945781643910259,
"nrd": 0.012251034586575862
}
}
+115
View File
@@ -0,0 +1,115 @@
# v2 module validation — genome-wide CNV / LOH / mosaicism (`simurg cn`) — interim
Date: 2026-09-29. Status: **not accepted yet** — constitutional calling works, mosaic calling produces mostly false
positives on the real-data set; see "Next steps". Spec: CYTO-001 … CYTO-040.
## Delivered
`simurg cn build-model` (GC 1 kb / 100 kb / 1 Mb, PFB from the cluster file, sex-aware batch reference from normal
GTCs), `simurg cn call` (GC-wave correction, smoothing, HMM with constitutional, gain-LOH, mosaic and haploid
states, events, post-filters, thresholds, centromere mask, CNV VCF + tabix, segments, summary, sidecar),
`simurg cn annotate` (JSON report, ISCN 2020, cytobands, RefSeq genes), `scripts/build_annotation_bundle.sh`.
Unit tests: `tests/unit/test_cn.py` (11).
## Synthetic implants (CYTO-022), GSA v4 demo, 22 synthetic samples, 216 events
Reference model from 30 samples, events implanted into the other 6 (`scripts/v2/cn_synthetic_eval.py`,
`V2_cn_synthetic_gsa36.json`), 4 replicates per cell:
| Event | 1 Mb | 5 Mb | 20 Mb |
|---|---|---|---|
| constitutional DEL / DUP | 4/4 · 2/4 | 4/4 · 4/4 | 4/4 · 4/4 |
| constitutional LOH (threshold 3 Mb) | 0/4 | 3/4 | 4/4 |
| mosaic loss / gain / CN-LOH, 15 % | 3/4 · 1/4 · 0/4 | **4/4 · 4/4 · 4/4** | 4/4 · 4/4 · 4/4 |
| mosaic, 5–10 % | mostly missed | mostly missed | 0–3/4 |
The CYTO-022 gate (≥ 90 % at 15 % / 5 Mb) is met on implanted data (12/12, small n).
## Real data: GSE165845 (360 GSA-MD arrays, 120 individuals × 3) vs NYGC 30x SV callset (freeze V3)
Cloud run 2026-09-29 (m7g.4xlarge, `cn call` 360 samples in 7 min 40 s; reference from 120 arrays of the same set).
Truth: non-reference DEL/DUP ≥ 25 kb of the same individual, ≥ 10 array probes; match = reciprocal overlap ≥ 50 %.
* Every sample was flagged `SampleQuality` (summary `logr_dev` median 0.246 — the GSA v1 C1 cluster file does not
match the GSA-MD chips, P8) and 46 fell into whole-chromosome-only mode, although the LRR used for calling has a
robust SD of 0.099 after the batch reference. The gate now uses the SD of the normalised LRR (K-027). The numbers
below ignore `SampleQuality` (`--ignore-filter`), evaluated from the run's sidecar.
| QUAL ≥ 20 | DEL | DUP |
|---|---|---|
| sensitivity 25–50 / 50–100 / 100–500 / 500–1000 kb | 7 / 8 / 8 / 7 % | 1 / 3 / 11 / 13 % |
| precision (all calls of the class) | 39 % | 34 % |
| Call type (QUAL ≥ 20) | calls | in NYGC truth | seen in another replicate |
|---|---|---|---|
| constitutional DEL | 227 | **77 %** | 50 % |
| constitutional DUP | 23 | **74 %** | 30 % |
| LOH | — | — | 74 % |
| mosaic loss | 399 | 12 % | 12 % |
| mosaic gain | 567 | 23 % | 23 % |
Constitutional calls are mostly true; the mosaic states generate most calls and most false positives (large
BAF-supported "mosaic" events are 0–2 % in truth and rarely reproduced: shifted heterozygous BAF bands of the
mismatched cluster file mimic allelic imbalance). Small BAF-supported mosaic gains are often real germline
duplications labelled mosaic (43 % in truth).
## Pi tuning rounds (2026-09-29), 40 arrays (20 individuals × replicates 1, 2), reference = replicate 3 of all 120
`scripts/v2/cn_tune.sh TAG [options]`; truth as above, QUAL ≥ 20, PASS only (after the K-027 noise-gate fix).
| Round | DEL calls / in truth | DUP | mosaic loss PASS / in truth | mosaic gain PASS / in truth | LOH replicated |
|---|---|---|---|---|---|
| base (per-probe reference het BAF spread in the emissions) | 22 / 100 % | 3 / 67 % | 29 / 11 % | 24 / 38 % | 80 % |
| `--mosaic-prior 0.0005` | 25 / 96 % | 4 / 75 % | 27 / 12 % | 22 / 36 % | 80 % |
| `--mosaic-prior 0.0005 --switch-distance 1e8` | 24 / 96 % | 4 / 75 % | 25 / 14 % | 23 / 35 % | 80 % |
| CYTO-041 (noise-normalised BAF split, complete-loss promotion) | 27 / 93 % | 4 / 75 % | 19 / 5 % | 19 / 42 % | 80 % |
| + CYTO-042 (per-probe LRR noise from the reference; **default**) | 20 / **100 %** | 4 / **100 %** | **11** / 9 % | **13** / **62 %** | 80 % |
* The mosaic prior and switch distance barely matter. CYTO-041 moves heterozygote-free "mosaic" losses (compressed
constitutional deletions) to DEL (`HIGHFRACTION`) and sends noise-driven mosaic calls to `LowFraction`
(MDEL 29 → 19 PASS, MDUP 24 → 19). On the implanted set (`data/v2cn/syn3`) MF estimates stay unbiased
(15 % → 0.16, 20 % → 0.21, 30 % → 0.30), detection is unchanged (126/216) and the CYTO-022 gate still passes.
* Remaining mosaic-loss false positives are long, low-amplitude LRR dips (−0.05 … −0.1) with a heterozygous BAF
spread only 1.0–1.3 × the probe's reference spread, or short events with < 20 heterozygous probes (MF from LRR).
Without phasing a < 20 % mosaic loss cannot be separated from regional BAF noise (K-027).
* CYTO-042 (per-probe emission SD = sample SD × the probe's reference LRR spread / median spread, clipped to
[0.5, 3]) halves the mosaic calls and makes constitutional calls fully precise, at the cost of 5 rare deletions
(rare DEL 24/64 → 19/64; rare DUP 10/68 → 12/68). Synthetic set with the rebuilt model (`data/v2cn/syn4`): calls
564 → 402, detection 126 → 124/216, the CYTO-022 gate (15 % / 5 Mb: 4/4 loss, gain, CN-LOH) still passes.
Disable with `--no-probe-lrr-sd`.
* **Sensitivity is limited by common CNVs.** Detected truth DEL by carrier frequency among the 120 individuals:
< 5 %: 24/64 (38 %), 5–20 %: 1/20, 20–50 %: 1/80, > 50 %: 0/156 (DUP 10/66, 0/2, 1/24, 0/40). The batch
reference is the per-probe median LRR, so an event carried by most reference samples is normalised away (and the
cluster file, trained on the population, already absorbs part of it). Array CN calling targets rare events; the
headline sensitivity figures above are dominated by common polymorphic deletions.
## Final full-cohort run (cloud, 2026-09-29, commit ccf7f4f, CYTO-041/042 defaults)
The run used m7g.4xlarge spot: 17 min in total (`cn build-model` 1 min 17 s, 6.0 GB; `cn call` of 360 samples 7 min 44 s, 1.7 GB).
Truth and matching are the same as above. PASS calls only, QUAL ≥ 20, no filters ignored
(`V2_cn_gse165845_final.json`).
* **Sample QC.** All 360 samples are `OK`; the first cloud run flagged every sample `SampleQuality` (the K-027 gate
fix). 14 samples carry `high_noise` and one carries `excess_events`.
* **The `excess_events` array.** 203610890020_R06C01 (NA19923) has 238 DEL and 688 mosaic calls, and none of its
147 CNV calls is in truth. It is a bad array, and it is the only one flagged.
* **Per-sample call counts (median).** 1 DEL, 0 DUP, 4 mosaic.
| | DEL | DUP |
|---|---|---|
| precision, PASS calls | 213/480 = **44 %** (first run, filters ignored: 39 %) | 142/249 = **57 %** (first run, filters ignored: 34 %) |
| precision, CNV calls without R06C01 | 355/582 = **61 %** (DEL + DUP) | |
| sensitivity 25–50 / 50–100 / 100–500 / 500–1000 kb | 5 / 7 / 8 / 9 % | 0 / 3 / 10 / 14 % |
| sensitivity by carrier frequency < 5 % / 5–20 % / ≥ 20 % | **30 %** / 5 % / 0.05 % | 13 % / 21 % / 0.6 % |
* **Replicate reproducibility.** CNV 35 %, LOH 85 %.
* **What CYTO-041/042 changed.** Precision is clearly higher and sensitivity is unchanged. The low overall
sensitivity is the common-CNV effect described above, not missed rare events.
* **Rare deletions.** 30 % of rare (< 5 %) truth deletions are found, the same as in the Pi tuning.
## Next steps
1. Mosaic losses below ~20 %: phasing-based BAF (MoChA-style, MIT) is the principled fix.
2. Rare-event sensitivity (currently DEL 30 %, DUP 18 % for events ≥ 25 kb with ≥ 10 probes): exclude reference
samples carrying the event when computing the per-probe median (iterative reference), size-aware transitions.
3. ~~A final full-cohort run~~ — done (section above).
+123
View File
@@ -0,0 +1,123 @@
{
"samples": 360,
"min_qual": 20,
"ignored_filters": [],
"sensitivity": {
"DEL": {
"100-500kb": {
"found": 128,
"truth": 1554,
"rate": 0.0824
},
"1000-infkb": {
"found": 0,
"truth": 15,
"rate": 0.0
},
"25-50kb": {
"found": 12,
"truth": 225,
"rate": 0.0533
},
"50-100kb": {
"found": 59,
"truth": 894,
"rate": 0.066
},
"500-1000kb": {
"found": 16,
"truth": 180,
"rate": 0.0889
}
},
"DUP": {
"100-500kb": {
"found": 109,
"truth": 1059,
"rate": 0.1029
},
"1000-infkb": {
"found": 9,
"truth": 21,
"rate": 0.4286
},
"25-50kb": {
"found": 0,
"truth": 198,
"rate": 0.0
},
"50-100kb": {
"found": 13,
"truth": 462,
"rate": 0.0281
},
"500-1000kb": {
"found": 11,
"truth": 78,
"rate": 0.141
}
}
},
"sensitivity_by_carrier_frequency": {
"DEL": {
"5-20%": {
"found": 15,
"truth": 285,
"rate": 0.0526
},
"common>=20%": {
"found": 1,
"truth": 1923,
"rate": 0.0005
},
"rare<5%": {
"found": 199,
"truth": 660,
"rate": 0.3015
}
},
"DUP": {
"5-20%": {
"found": 21,
"truth": 102,
"rate": 0.2059
},
"common>=20%": {
"found": 5,
"truth": 855,
"rate": 0.0058
},
"rare<5%": {
"found": 116,
"truth": 861,
"rate": 0.1347
}
}
},
"precision": {
"DEL": {
"in_truth": 213,
"calls": 480,
"rate": 0.4437
},
"DUP": {
"in_truth": 142,
"calls": 249,
"rate": 0.5703
}
},
"replicate_reproducibility": {
"CNV": {
"matched": 512,
"comparisons": 1458,
"rate": 0.3512
},
"LOH": {
"matched": 356,
"comparisons": 420,
"rate": 0.8476
}
},
"calls_per_sample_median": 1.0,
"loh_calls_per_sample_median": 0.0
}
+89
View File
@@ -0,0 +1,89 @@
{
"samples": 360,
"min_qual": 10.0,
"ignored_filters": [
"SampleQuality"
],
"sensitivity": {
"DEL": {
"100-500kb": {
"found": 230,
"truth": 1554,
"rate": 0.148
},
"1000-infkb": {
"found": 0,
"truth": 15,
"rate": 0.0
},
"25-50kb": {
"found": 54,
"truth": 225,
"rate": 0.24
},
"50-100kb": {
"found": 132,
"truth": 894,
"rate": 0.1477
},
"500-1000kb": {
"found": 51,
"truth": 180,
"rate": 0.2833
}
},
"DUP": {
"100-500kb": {
"found": 279,
"truth": 1059,
"rate": 0.2635
},
"1000-infkb": {
"found": 9,
"truth": 21,
"rate": 0.4286
},
"25-50kb": {
"found": 4,
"truth": 198,
"rate": 0.0202
},
"50-100kb": {
"found": 55,
"truth": 462,
"rate": 0.119
},
"500-1000kb": {
"found": 19,
"truth": 78,
"rate": 0.2436
}
}
},
"precision": {
"DEL": {
"in_truth": 473,
"calls": 1814,
"rate": 0.2607
},
"DUP": {
"in_truth": 367,
"calls": 1494,
"rate": 0.2456
}
},
"replicate_reproducibility": {
"CNV": {
"matched": 2132,
"comparisons": 6616,
"rate": 0.3222
},
"LOH": {
"matched": 400,
"comparisons": 532,
"rate": 0.7519
}
},
"calls_per_sample_median": 6.0,
"loh_calls_per_sample_median": 0.0
}
+89
View File
@@ -0,0 +1,89 @@
{
"samples": 360,
"min_qual": 20.0,
"ignored_filters": [
"SampleQuality"
],
"sensitivity": {
"DEL": {
"100-500kb": {
"found": 126,
"truth": 1554,
"rate": 0.0811
},
"1000-infkb": {
"found": 0,
"truth": 15,
"rate": 0.0
},
"25-50kb": {
"found": 16,
"truth": 225,
"rate": 0.0711
},
"50-100kb": {
"found": 70,
"truth": 894,
"rate": 0.0783
},
"500-1000kb": {
"found": 13,
"truth": 180,
"rate": 0.0722
}
},
"DUP": {
"100-500kb": {
"found": 117,
"truth": 1059,
"rate": 0.1105
},
"1000-infkb": {
"found": 6,
"truth": 21,
"rate": 0.2857
},
"25-50kb": {
"found": 1,
"truth": 198,
"rate": 0.0051
},
"50-100kb": {
"found": 15,
"truth": 462,
"rate": 0.0325
},
"500-1000kb": {
"found": 10,
"truth": 78,
"rate": 0.1282
}
}
},
"precision": {
"DEL": {
"in_truth": 222,
"calls": 571,
"rate": 0.3888
},
"DUP": {
"in_truth": 149,
"calls": 436,
"rate": 0.3417
}
},
"replicate_reproducibility": {
"CNV": {
"matched": 554,
"comparisons": 2014,
"rate": 0.2751
},
"LOH": {
"matched": 286,
"comparisons": 394,
"rate": 0.7259
}
},
"calls_per_sample_median": 2.0,
"loh_calls_per_sample_median": 0.0
}
@@ -0,0 +1,97 @@
{
"events": 216,
"detected": 126,
"table": {
"MDEL f=0.10": {
"5 Mb": "1/4",
"20 Mb": "3/4",
"1 Mb": "1/4"
},
"MLOH f=0.10": {
"5 Mb": "0/4",
"20 Mb": "0/4",
"1 Mb": "0/4"
},
"MDUP f=0.20": {
"1 Mb": "4/4",
"20 Mb": "4/4",
"5 Mb": "3/4"
},
"MLOH f=0.15": {
"1 Mb": "0/4",
"5 Mb": "4/4",
"20 Mb": "4/4"
},
"MLOH f=0.30": {
"1 Mb": "0/4",
"20 Mb": "4/4",
"5 Mb": "2/4"
},
"DEL": {
"1 Mb": "4/4",
"5 Mb": "4/4",
"20 Mb": "4/4"
},
"MLOH f=0.20": {
"1 Mb": "0/4",
"5 Mb": "4/4",
"20 Mb": "4/4"
},
"MLOH f=0.05": {
"1 Mb": "0/4",
"5 Mb": "0/4",
"20 Mb": "0/4"
},
"MDEL f=0.20": {
"1 Mb": "4/4",
"20 Mb": "4/4",
"5 Mb": "3/4"
},
"MDEL f=0.30": {
"20 Mb": "4/4",
"5 Mb": "4/4",
"1 Mb": "4/4"
},
"MDUP f=0.10": {
"20 Mb": "2/4",
"1 Mb": "0/4",
"5 Mb": "2/4"
},
"MDUP f=0.30": {
"5 Mb": "4/4",
"20 Mb": "4/4",
"1 Mb": "4/4"
},
"DUP": {
"1 Mb": "2/4",
"20 Mb": "4/4",
"5 Mb": "4/4"
},
"MDEL f=0.05": {
"5 Mb": "0/4",
"20 Mb": "0/4",
"1 Mb": "0/4"
},
"LOH": {
"5 Mb": "3/4",
"20 Mb": "4/4",
"1 Mb": "0/4"
},
"MDUP f=0.15": {
"1 Mb": "1/4",
"5 Mb": "4/4",
"20 Mb": "4/4"
},
"MDEL f=0.15": {
"20 Mb": "4/4",
"5 Mb": "4/4",
"1 Mb": "3/4"
},
"MDUP f=0.05": {
"5 Mb": "0/4",
"1 Mb": "0/4",
"20 Mb": "0/4"
}
},
"calls_total": 564
}
+53
View File
@@ -0,0 +1,53 @@
# v2 module validation — methylation control QC (`simurg methyl qc`)
Date: 2026-09-29. Development on the Raspberry Pi; oracle runs on AWS (c7g.xlarge, five short runs, ≈ $0.025).
Spec: MET-001 … MET-018 (and QC-004/QC-006, QC-062).
## Data
* **minfiDataEPIC** (Bioconductor, Artistic-2.0): 3 EPIC v1 arrays of NA12878 (IDATs + minfi-style sample sheet).
* **Infinium MethylationEPIC v1.0 B5 manifest (CSV)**: 865 918 probes (142 158 Infinium I: 92 212 red, 49 946 green),
635 control beads. Read in 8.6 s on the Pi.
## Delivered
`simurg methyl qc`, `simurg methyl convert-manifest`, `simurg qc controls --array-type methylation`, methylation
datasets in `simurg qc report`; modules `formats/methyl_manifest.py`, `signal/methyl.py`, `pipeline/methyl.py`;
the 21 methylation metrics in `qc/controls.py`. User guide: "Methylation arrays".
## Results on minfiDataEPIC
| Check | Result |
|---|---|
| Run time, 3 samples, 3 workers (Pi) | 25 s (2.9 s signal processing per sample) |
| 21 control metrics | all evaluable, all pass the `methylation_epic` profile (e.g. staining green 57, BS conversion I green 13, BS conversion II 5.3, specificity II 24) |
| Probes passing detection (p < 0.05, analytical CG + SNP probes) | 90.9 %, 91.1 %, 91.3 % |
| β of the three NA12878 replicates | Pearson r 0.997 – 0.9974 between replicates; bimodal distribution |
| Sheet `Sample_Group` → PCA colours; outputs of MET-013 | present; folder loads in `qc report` |
## Against the sesame oracle (sesame 1.28.1, same probe set via `probes.tsv`/`controls.tsv`)
`scripts/v2/sesame_oracle.R` + `scripts/v2/methyl_oracle_compare.py`, bead-count masking off on both sides (sesame's
`min_beads` masks every Infinium II probe because their M address is NA), 865 918 probes per sample:
| Quantity | Agreement (3 samples) |
|---|---|
| β values | Pearson r 0.99998; \|Δβ\| median 0.0009, 99th percentile 0.008, max 0.011 |
| noob-corrected M/U signals | relative difference median 0.5 %, max 5 % |
| Detection calls at p < 0.05 | 99.0 – 99.1 % identical; passing fraction 91.0 – 91.4 % (simurg) vs 92.0 – 92.3 % (sesame) |
| Detection p-values | not identical (median \|Δp\| 0.0008) |
The remaining difference is systematic (same size in all three samples) and is not the negative controls: sesame did
not attach the supplied control table (`negControls` failed, so its background is out-of-band Infinium I signal only),
and simurg without negative controls is not closer (noob difference 0.56 % instead of 0.53 %). The exact cause is
open (K-026). For QC use — pass/fail per sample, detection rate, β for a PCA — the agreement is sufficient; simurg's
methylation numerics are documented as its own [OURS] implementation of the published methods (MET-010), not as a
bit-exact port.
## Spec deviations (K-026)
* MET-002: the vendor EPIC manifest has a 5th field (`AVG`) on some `NEGATIVE` control lines; ≥ 4 fields are
accepted and the first four used. Fewer than 4 still gives `E_METH_CONTROLS_FORMAT`.
* MET-010: the detection p-value follows sesame's pOOBAH (p = 1 − max(F(M), F(U)), each in its own channel) instead
of the ambiguous "1 − ECDF(M + U)", so an open-source oracle exists.
* MET-004: `Sentrix_ID`/`Sentrix_Position` (minfi/sesame sheets) accepted as a third key-column pair.
@@ -0,0 +1,263 @@
{
"200144450018_R04C01": {
"probes": 865918,
"masked_sesame": 0,
"masked_simurg": 0,
"combine_neg=True": {
"noob_fit": {
"green": {
"mu": 883.7232985218219,
"sigma": 533.736,
"alpha": 6615.19430861235
},
"red": {
"mu": 1475.9648221678526,
"sigma": 974.0681999999999,
"alpha": 7235.1788325587695
}
},
"pval_abs_diff": {
"median": 0.0007849171113696351,
"p99": 0.012655374352560589,
"max": 0.7810294625483152
},
"pval_identical_fraction": 2.7716250268501175e-05,
"detection_call_agreement_0.05": 0.9904228806884716,
"prop_passing": {
"simurg": 0.9104377127786546,
"sesame": 0.9199708917641769
},
"noob_m_rel_diff": {
"median": 0.005309072922507809,
"p99": 0.03942181326074589,
"max": 0.04794913709182962
},
"noob_u_rel_diff": {
"median": 0.005799391773898118,
"p99": 0.03459884716823513,
"max": 0.04794913709182962
},
"beta_abs_diff": {
"median": 0.0008897385559986926,
"p99": 0.008521359578262878,
"max": 0.011219185321570957
},
"beta_corr": 0.9999813374332573
},
"combine_neg=False": {
"noob_fit": {
"green": {
"mu": 885.3040134669028,
"sigma": 533.736,
"alpha": 6613.613593667269
},
"red": {
"mu": 1481.7310124080875,
"sigma": 975.5508,
"alpha": 7229.412642318534
}
},
"pval_abs_diff": {
"median": 0.0007957874497818184,
"p99": 0.013082543985806876,
"max": 0.7841804034247452
},
"pval_identical_fraction": 2.7716250268501175e-05,
"detection_call_agreement_0.05": 0.9901872925611894,
"prop_passing": {
"simurg": 0.9102024830066768,
"sesame": 0.9199708917641769
},
"noob_m_rel_diff": {
"median": 0.005602741250468986,
"p99": 0.038392170387569056,
"max": 0.047002982560608926
},
"noob_u_rel_diff": {
"median": 0.0065460727630588,
"p99": 0.03442105845709709,
"max": 0.047002982560608926
},
"beta_abs_diff": {
"median": 0.0009450303433635281,
"p99": 0.008636600261016411,
"max": 0.011550874711670933
},
"beta_corr": 0.9999799503820251
}
},
"200144450019_R07C01": {
"probes": 865918,
"masked_sesame": 0,
"masked_simurg": 0,
"combine_neg=True": {
"noob_fit": {
"green": {
"mu": 1004.7491370858492,
"sigma": 606.3833999999999,
"alpha": 7726.238894681398
},
"red": {
"mu": 1645.4509315964758,
"sigma": 1097.124,
"alpha": 8498.773085184836
}
},
"pval_abs_diff": {
"median": 0.000796427075933526,
"p99": 0.012417625677686978,
"max": 0.8027931613655367
},
"pval_identical_fraction": 5.65873442981899e-05,
"detection_call_agreement_0.05": 0.9906884947535448,
"prop_passing": {
"simurg": 0.912474825779329,
"sesame": 0.9217102015559928
},
"noob_m_rel_diff": {
"median": 0.005314312414259365,
"p99": 0.04273539399348046,
"max": 0.051907861845576456
},
"noob_u_rel_diff": {
"median": 0.005468488622733155,
"p99": 0.03355605525549436,
"max": 0.051907861845576456
},
"beta_abs_diff": {
"median": 0.0008606416392141769,
"p99": 0.008394710099924632,
"max": 0.010860663350041877
},
"beta_corr": 0.9999812358896786
},
"combine_neg=False": {
"noob_fit": {
"green": {
"mu": 1006.5499088107555,
"sigma": 606.3833999999999,
"alpha": 7724.4381229564915
},
"red": {
"mu": 1651.850010672488,
"sigma": 1098.6065999999998,
"alpha": 8492.374006108823
}
},
"pval_abs_diff": {
"median": 0.0008019741538315371,
"p99": 0.012866482361703901,
"max": 0.8059396632834097
},
"pval_identical_fraction": 5.65873442981899e-05,
"detection_call_agreement_0.05": 0.9904552163137849,
"prop_passing": {
"simurg": 0.9122453898440994,
"sesame": 0.9217102015559928
},
"noob_m_rel_diff": {
"median": 0.005610084745351353,
"p99": 0.04163859014373106,
"max": 0.05094825885592487
},
"noob_u_rel_diff": {
"median": 0.006166215991548127,
"p99": 0.033209584923356225,
"max": 0.05094825885592487
},
"beta_abs_diff": {
"median": 0.0009157086256231864,
"p99": 0.008483978716612176,
"max": 0.011139138206961108
},
"beta_corr": 0.999980025075608
}
},
"200144450021_R05C01": {
"probes": 865918,
"masked_sesame": 0,
"masked_simurg": 0,
"combine_neg=True": {
"noob_fit": {
"green": {
"mu": 1075.2185580103821,
"sigma": 618.2442,
"alpha": 7644.388814794943
},
"red": {
"mu": 1583.358547469605,
"sigma": 1043.7504,
"alpha": 7872.027822306209
}
},
"pval_abs_diff": {
"median": 0.0008097160213839151,
"p99": 0.012557480308668073,
"max": 0.7179490998567224
},
"pval_identical_fraction": 8.083906328312843e-05,
"detection_call_agreement_0.05": 0.9906931141285895,
"prop_passing": {
"simurg": 0.9142593274977809,
"sesame": 0.9234993383438433
},
"noob_m_rel_diff": {
"median": 0.005312767934935867,
"p99": 0.0416027184427129,
"max": 0.05163890421954045
},
"noob_u_rel_diff": {
"median": 0.005627520758233918,
"p99": 0.033403350351144646,
"max": 0.05163890421954045
},
"beta_abs_diff": {
"median": 0.000886446134372592,
"p99": 0.008067365546443757,
"max": 0.01057770765422894
},
"beta_corr": 0.9999820250823297
},
"combine_neg=False": {
"noob_fit": {
"green": {
"mu": 1077.1430682973066,
"sigma": 618.2442,
"alpha": 7642.464304508018
},
"red": {
"mu": 1589.590396661226,
"sigma": 1045.233,
"alpha": 7865.7959731145875
}
},
"pval_abs_diff": {
"median": 0.0008198122345790535,
"p99": 0.013020971906371236,
"max": 0.7195549302039842
},
"pval_identical_fraction": 8.083906328312843e-05,
"detection_call_agreement_0.05": 0.9905060294392771,
"prop_passing": {
"simurg": 0.9140716071871386,
"sesame": 0.9234993383438433
},
"noob_m_rel_diff": {
"median": 0.005623618294636237,
"p99": 0.04045226512957654,
"max": 0.05065151257916101
},
"noob_u_rel_diff": {
"median": 0.006360115250782237,
"p99": 0.03307310255358611,
"max": 0.05065151257916101
},
"beta_abs_diff": {
"median": 0.0009431797599571898,
"p99": 0.008174273334787542,
"max": 0.010885211471086453
},
"beta_corr": 0.9999807797628828
}
}
}
+61
View File
@@ -0,0 +1,61 @@
# V2 — Targeted PGx copy number (PCN-001 … PCN-017)
Status: **implemented, interim validation** (2026-09-29). `simurg pgx copy-number train|call` (CLI-031/032), model
file `.simurg-pgxcn` (IN-050), region BED v1 built from UCSC ncbiRefSeq (`scripts/pgx/build_cn_regions.py`,
packaged as `simurg/pgx/data/pgx_cn_regions.GRCh38.v1.bed`), per-sample `*.pgx_cn.vcf.gz` + `.tbi`,
`pgx_cn_batch_summary.tsv`, `pgx_cn_sample_summary.tsv`. Unit tests: `tests/unit/test_pgx_cn.py`.
## Data
* **Arrays:** the GSA v4 PGx demo (`idats_Demo_36`, 36 arrays), genotyped by SimurgArray. Its CNV probes cover every
region (51–357 probes; GSTT1: 160 probes on `chr22_KI270879v1_alt` in the GRCh38 manifest).
* **Identities:** the demo arrays are 30 CEPH / 1000 Genomes individuals (6 replicate arrays). Identified by
genotype concordance with the 3202-sample 1000G 30x panel on 4647 chr22 SNPs: best match 0.992–0.996, next best
≤ 0.91 (relatives) — `scripts/pgx/demo36_identity.tsv`.
* **Truth:** NYGC 30x SV callset (1KGP_3202 GATK-SV/svtools freeze V3), RD_CN per individual
(`scripts/pgx/validate_cn_truth.py`).
* The GSE165845 GSA-MD v1 arrays (360 arrays, CN/LOH validation) have 0–9 probes per PGx region: not usable here.
## Result (30 individuals, model trained on the same 36 arrays with `--min-samples 30`)
| Region | Concordance with NYGC RD_CN | Mismatches (truth → call) |
|---|---|---|
| GSTM1 (whole gene) | **30/30** | – |
| UGT2B17 (whole gene) | **30/30** | – |
| SULT1A1 | 29/30 | 2→1 |
| CYP2A6.exon.1 | 29/30 | 3→2 |
| CYP2A6.intron.7 | 28/30 | 3→2, 1→2 |
| CYP2D6.p5 | 28/30 | 4→3, 2→1 |
| CYP2D6.intron.2 | 25/30 | 3→2 ×3, 4→3, 2→1 |
| CYP2D6.exon.9 | 25/30 | 3→2 ×3, 4→2, 2→1 |
| **all** | **224/240 (93.3 %)** | |
GSTT1 (no SV truth on the alt contig): CN 0/1/2 = 5/16/9, in Hardy–Weinberg proportions (null allele 0.43; CEU
literature ≈ 0.4–0.5). Details: `V2_pgx_cn_demo36.json`.
* CYP2D6 truth is one gene-level record; the exon-9 / intron-2 "misses" at truth CN 3 are expected for tandem
structures such as `*68+*4` or `*36+*10`, where not every sub-region is duplicated. Star-allele truth (GeT-RM)
is the right yardstick and follows with the star-allele caller.
* PCN-017 target (≥ 95 % per region) is met for GSTM1, UGT2B17, SULT1A1, CYP2A6.exon.1; not (yet) shown for the
CYP2D6 sub-regions.
## Additions
* PCN-006: a warning when the batch's IDAT scan dates span more than 14 days (no sample-sheet input in `pgx
copy-number call`, so the lot/batch-column check does not apply).
* PCN-010: `pgx_cn_baf_evidence.tsv` — per region the log-likelihood of CN 0 … 5 from the informative SNP BAFs
(binomial weights from the training cohort's B-allele frequency); `--bafs-in-cn` adds it to the CN posterior
(experimental, off by default). On the demo it agrees poorly with the LRR calls (e.g. GSTM1 2/36): the SNPs in
these regions do not separate the CN states here, so it stays informational.
* PCN-009 / EDGE-026: training excludes samples whose genome-wide level **or** noise is an outlier (robust |z| > 5);
`test_outlier_robustness` injects 10 % noisy, shifted arrays and requires ≥ 99 % unchanged calls for the others.
* CLI-032: the platform (LCG/EX) comes from the CSV manifest's `Assay Format` (`--csv`), or `--platform`; the demo
model records `EX`.
## Caveats (K-028)
* Trained and evaluated on the same 30 individuals (the spec requires ≥ 96 training samples; the priors were
chosen while looking at this truth). This is an optimistic, in-sample figure until an independent cohort is
available.
* Deviations from PCN-009: reference from the training cohort (not the calling batch); no GC-correction step; CN
model = compressed-intensity template + HWE-constrained weights (`simurg/pgx/cn.py`).
+89
View File
@@ -0,0 +1,89 @@
{
"individuals": 30,
"regions": {
"GSTM1": {
"record": "HGSV_9691",
"concordant": 30,
"n": 30,
"rate": 1.0,
"truth_to_call_mismatches": {}
},
"UGT2B17": {
"record": "HGSV_63272",
"concordant": 30,
"n": 30,
"rate": 1.0,
"truth_to_call_mismatches": {}
},
"SULT1A1": {
"record": "HGSV_207805",
"concordant": 29,
"n": 30,
"rate": 0.9667,
"truth_to_call_mismatches": {
"2->1": 1
}
},
"CYP2A6.intron.7": {
"record": "HGSV_232737",
"concordant": 28,
"n": 30,
"rate": 0.9333,
"truth_to_call_mismatches": {
"3->2": 1,
"1->2": 1
}
},
"CYP2A6.exon.1": {
"record": "HGSV_232737",
"concordant": 29,
"n": 30,
"rate": 0.9667,
"truth_to_call_mismatches": {
"3->2": 1
}
},
"CYP2D6.exon.9": {
"record": "HGSV_247317",
"concordant": 25,
"n": 30,
"rate": 0.8333,
"truth_to_call_mismatches": {
"4->2": 1,
"3->2": 3,
"2->1": 1
}
},
"CYP2D6.intron.2": {
"record": "HGSV_247317",
"concordant": 25,
"n": 30,
"rate": 0.8333,
"truth_to_call_mismatches": {
"4->3": 1,
"3->2": 3,
"2->1": 1
}
},
"CYP2D6.p5": {
"record": "HGSV_247317",
"concordant": 28,
"n": 30,
"rate": 0.9333,
"truth_to_call_mismatches": {
"4->3": 1,
"2->1": 1
}
}
},
"overall": {
"concordant": 224,
"n": 240,
"rate": 0.9333
},
"gstt1_cn_distribution": {
"0": 5,
"1": 16,
"2": 9
}
}
+154
View File
@@ -0,0 +1,154 @@
# V2 — PGx DB and star-allele calling (PGX-001 … PGX-021)
Status: **implemented for CPIC star-allele genes, interim validation** (2026-09-29).
* `scripts/pgx/fetch_sources.sh` → CPIC API tables (CC0, data version 2026-08-03).
* `simurg pgx db build` → deterministic SQLite: 22 genes, 1354 alleles, 1897 normalised variant rows; GSA v4 PGx
probe map 5002 links; two builds byte-identical.
* `simurg pgx star-allele call` → `diplotype_summary.tsv`, `pgx_calls.json` (11 star-allele genes).
* `simurg pgx phenotype` (alias `annotate`) → per-sample `<sample>.pgx.json` (`simurg.pgx/1`) and
`diplotype_annotations.tsv` with `#` metadata lines (PGX-024 … PGX-028); CPIC activity scores (xN = N × activity,
*5/*0 = 0) and function-count lookups; disagreeing feasible solutions → `Indeterminate` plus per-solution
phenotypes. `tests/unit/test_pgx_phenotype.py`.
* Unit tests: `tests/unit/test_pgx_db.py`, `tests/unit/test_pgx_star.py`.
## Data and truth
* Arrays: the GSA v4 PGx demo (36 arrays = 30 CEPH/1000G individuals, `scripts/pgx/demo36_identity.tsv`), SNV VCFs
and PGx CN VCFs from SimurgArray.
* Star-allele truth: GeT-RM consolidated PGx/HLA table (CDC, 2025 V4) — 16 of the 30 individuals
(`scripts/pgx/getrm_truth.py`).
* Probe mask (PGX-010): array vs 1000G 30x (NYGC phased panel, fetched per gene over HTTPS) for the **other 14
individuals** (`scripts/pgx/derive_masks.py`), so the mask is independent of the GeT-RM validation set: 8 of 658
assessed records (23 probe names) below 95 % concordance — e.g. CYP2C19 rs367543002 and CYP2D6 rs1135836 called
homozygous ALT in every sample, the UGT1A1 `C>CATAT` TA-repeat probe. Regenerate with the command in the script
header (the mask lists vendor probe names and is not committed).
## Result (16 individuals; replicate arrays counted once)
| Gene | Top solution = GeT-RM | Truth among array-equivalent feasible solutions |
|---|---|---|
| CYP2C9 | **15/15** | 15/15 |
| CYP2C19 | **14/15** | 15/15 |
| CYP2D6 | **13/15** | **15/15** |
| CYP3A5 | 15/15 | 15/15 |
| TPMT | 15/16 | 15/16 |
| CYP2B6 | 12/14 | 12/14 |
| CYP4F2 | 8/9 | 8/9 |
| NAT2 | 8/9 | 8/9 |
| NUDT15 | 1/1 | 1/1 |
| VKORC1 (rs9923231, c.-1639G>A) | 13/15 | 13/15 |
| GSTM1 (CN: DEL/DEL vs present) | 9/9 | 9/9 |
| GSTT1 (CN 0/1/2) | 7/9 | 7/9 |
| UGT2B17 (CN) | 2/2 | 2/2 |
| CYP3A4 (PharmVar) | **15/15** | 15/15 |
| CYP2A6 (PharmVar; *4 deletion from CN) | 7/9 | 8/9 |
| **total** | **154/168 (91.7 %)** | **158/168 (94.0 %)** |
PGX-032 target (≥ 90 % for CYP2D6/CYP2C19/CYP2C9 against truth): **42/45 = 93 %** (top solution), 45/45 within
the array-equivalent feasible solutions.
CYP2D6 structures (PGX-013) come from the three sub-region CNs (`simurg.pgx.star.cyp2d6_structure`): NA12878
`*3/*68+*4` and NA11832 `*1/*68+*4` are called exactly as GeT-RM reports them; one sample is called `*36+*10/*4`
(p5 = intron 2 = 3, exon 9 = 2; no GeT-RM truth). The two remaining top-solution misses are *1 vs *2, which this
array cannot separate (both within the feasible set).
Details and every discordant call: `V2_pgx_star_getrm16.json`.
Not in the total (GeT-RM uses older names or definitions than CPIC/PharmVar today): UGT1A1 (GeT-RM `*60`;
0/9 top), SLCO1B1 (`*1B/*17/*21` vs CPIC `*37/*15/*20`; 9/15), CYP1A2 (lettered `*1F`; 0/9) and CYP2C8 (PharmVar now
defines rs2071426 alone as `*19`; the array calls it concordantly with 1000G, GeT-RM reports those carriers as
`*1`; 8/15).
Remaining discordances: CYP2D6 *2 vs *1 where the array cannot separate them (prior picks *1), CYP2C19 *8, TPMT *21, CYP2B6 *27, CYP4F2 *2 — rare
alleles whose defining variants are absent or masked on this array.
## Error model (K-029)
After masking, 0 of 9 740 array calls at DB variants disagreed with 1000G 30x in any GenCall-score bin (0.15–1.0),
so the per-call error is 1e-3 (GS ≥ 0.15) and errors are handled by the probe mask. The first model,
ε = 0.5·(1 − GS)², let correct low-GS calls lose to the prior (CYP2C9 7/15 → 15/15 after the change).
## Caveats
* 16 individuals, one array type, European (CEPH) only; masks from 14 individuals (spec: ≥ 100 samples).
* 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
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.
* All CPIC gene kinds are called: star alleles, G6PD (named haplotypes; males hemizygous from the VCF ploidy),
variant-based genes (ABCG2, CACNA1S, CFTR, DPYD, IFNL3, MT-RNR1, RYR1, VKORC1; genotype as `rs… REF/ALT` or
`Reference`, phenotype diplotype from the two lowest-activity carried variants), HLA-A/B tag variants (PGX-007,
positions resolved from the manifest's probes, flag `tag_caveat`), and GSTM1/GSTT1/UGT2B17 `*0` genotypes from
the targeted CN (PGX-006).
* PharmVar genes without CPIC definitions (CYP1A2, CYP2A6, CYP2A13, CYP2C8, CYP3A4; PharmVar 6.2.29): core alleles
from the GRCh38 haplotype VCFs, `*1` = reference, prior reference 0.8 (no population frequencies); genotype only
(no CPIC phenotype). The probe mask was re-derived with these variants (one more record: CYP2A6 19:40843845 C>A,
concordance 0.47, which had produced false *5 calls).
* ClinPGx clinical-annotation variants (CC BY-SA 4.0; levels 1A–2B, and level 3 for the PGX-008 parity genes) add
genotype-only variant-based genes — OPRM1, COMT, DRD2, ANKK1, MTHFR, F5, … (43 genes, 171 of 187 rsIDs resolved on
GSA v4 PGx from the manifest's probes). The DB now holds 70 genes (16 star-allele, G6PD, 2 HLA tag, 51
variant-based; spec parity list: 58). `pgx coverage`: 1225 alleles callable, 93 array-equivalent, 437 not
covered, 5 masked.
* `--guideline dpwg` (PGX-025): ClinPGx curates DPWG guidelines with the CPIC allele-function and phenotype mappings
for genes CPIC covers, so those genes reuse the CPIC tables (flag `dpwg_via_cpic_mapping`); DPWG-only genes
(CYP1A2, CYP3A4, F5) have no public machine-readable mapping and get phenotype `null` with the flag
`dpwg_mapping_not_available` (K-029).
## Independent validation: GSE165845 (GSA v1 C2, 120 1000 Genomes individuals × 3 arrays)
A second cohort, independent of the demo used for development (2026-09-29, all on the Pi, $0):
* **Arrays:** the 360 GSE165845 GTCs (GSA-MD v1 chips, GSA v1 C2 manifest; K-024) → SNV VCFs; no targeted CN probes
on this chip, so star alleles are called with `--no-cn`. The chip carries little PGx content: 292 probe-to-variant
links (GSA v4 PGx: 5 280); `pgx coverage`: 495 alleles callable, 136 array-equivalent, 1 129 not covered — e.g.
no CYP2D6 *2/*4/*10 probes (rs16947, rs3892097, rs1065852 are absent from the manifest).
* **Truth:** star alleles derived from the 1000G 30x phased panel with the same DB definitions
(`scripts/pgx/wgs_star_truth.py`; SNV/indel level — CYP2D6 *5, xN and hybrids invisible; CYP2B6 left out because
its common core variant rs2279343 is filtered from the panel). The derivation was checked against GeT-RM on the
demo's CEPH individuals (CYP2D6 14/15, CYP2C9/CYP3A4/CYP3A5 15/15, TPMT 16/16; the rest are nomenclature changes).
* **Masks:** 2-fold — individuals split in halves; each half is called with the mask derived from the other half
(A → 0 probes masked, B → 1). After masking the calls agree with 1000G at 99 % or better in every GS bin.
* **Metrics:** *top* = the top solution equals the WGS diplotype; *array-resolvable* = both diplotypes projected
onto the alleles this array can tell apart (`pgx coverage`: callable → itself, equivalent_to:X → X, not covered →
the reference's class) — the fair measure for a chip that lacks a gene's defining variants.
| Gene | Top = WGS | Truth within feasible equivalents | Array-resolvable |
|---|---|---|---|
| CYP1A2 | 18/120 | 72/120 | 120/120 |
| CYP2A13 | 97/120 | 119/120 | 117/120 |
| CYP2A6 | 73/120 | 112/120 | 117/120 |
| CYP2C19 | 100/120 | 116/120 | 110/120 |
| CYP2C8 | 114/120 | 120/120 | 115/120 |
| CYP2C9 | 95/120 | 108/120 | 116/120 |
| CYP2D6 (SNV level) | 32/120 | 54/120 | 111/120 |
| CYP3A4 | 112/120 | 117/120 | 117/120 |
| CYP3A5 | 120/120 | 120/120 | 120/120 |
| CYP4F2 | 43/120 | 86/120 | 96/120 |
| NAT2 | 95/120 | 105/120 | 120/120 |
| NUDT15 | 120/120 | 120/120 | 120/120 |
| SLCO1B1 | 31/120 | 87/120 | 109/120 |
| TPMT | 107/120 | 115/120 | 102/103 |
| UGT1A1 | 66/120 | 120/120 | 47/47 |
| **total** | **1223/1800 (67.9 %)** | | **1637/1710 (95.7 %)** |
* Top-solution concordance is low where the chip lacks the defining variants: the call is then the most probable
member of an equivalence class the array cannot split (CYP2D6 *1/*2/*10, SLCO1B1 *1/*37, CYP1A2), so the prior
decides. Projected onto what the array can see, 95.7 % agree (half A 815/854, half B 822/856).
* CYP4F2 (96/120) is the weakest: the array genotypes at its covered sites match 1000G exactly (rs3093200 180/180),
so the residue is definitional — *5 and *23 share the only covered variant, and the projection cannot separate
alleles whose other defining variants are off-array.
* **GeT-RM** (7 of the 120 individuals, older consensus): 28/35 top, array-resolvable 29/30.
* **Replicates:** the top genotype of all 70 genes agrees between replicate arrays of one individual in 24 916 of
25 200 pairs (98.9 %); 87 of the 284 discordant pairs come from NA20362's failed replicate (call rate 0.76, K-024);
without it 99.2 %.
Files: `docs/validation/V2_pgx_star_gse165845.json`; scripts `wgs_star_truth.py`, `validate_star_getrm.py`
(`--all-genes --individuals --coverage`), `replicate_concordance.py`.
+429
View File
@@ -0,0 +1,429 @@
{
"individuals": 30,
"genes": {
"CYP1A2": {
"n": 9,
"top": 0,
"equivalent": 0
},
"CYP2A6": {
"n": 9,
"top": 7,
"equivalent": 8
},
"CYP2B6": {
"n": 14,
"top": 12,
"equivalent": 12,
"not_comparable": 1
},
"CYP2C19": {
"n": 15,
"top": 14,
"equivalent": 15
},
"CYP2C8": {
"n": 15,
"top": 8,
"equivalent": 9
},
"CYP2C9": {
"n": 15,
"top": 15,
"equivalent": 15
},
"CYP2D6": {
"n": 15,
"top": 13,
"equivalent": 15
},
"CYP3A4": {
"n": 15,
"top": 15,
"equivalent": 15
},
"CYP3A5": {
"n": 15,
"top": 15,
"equivalent": 15
},
"CYP4F2": {
"n": 9,
"top": 8,
"equivalent": 8
},
"GSTM1": {
"n": 9,
"top": 9,
"equivalent": 9
},
"GSTT1": {
"n": 9,
"top": 7,
"equivalent": 7
},
"NAT2": {
"n": 9,
"top": 8,
"equivalent": 8
},
"NUDT15": {
"n": 1,
"top": 1,
"equivalent": 1
},
"SLCO1B1": {
"n": 15,
"top": 9,
"equivalent": 9
},
"TPMT": {
"n": 16,
"top": 15,
"equivalent": 15
},
"UGT1A1": {
"n": 9,
"top": 0,
"equivalent": 1
},
"UGT2B17": {
"n": 2,
"top": 2,
"equivalent": 2
},
"VKORC1": {
"n": 15,
"top": 13,
"equivalent": 13
}
},
"legacy_nomenclature_genes": [
"CYP1A2",
"CYP2C8",
"SLCO1B1",
"UGT1A1"
],
"total": {
"n": 168,
"top": 154,
"equivalent": 158
},
"discordant": [
{
"individual": "NA12156",
"gene": "CYP1A2",
"truth": "*1A/*1F",
"call": "*1/*15",
"within_equivalents": false
},
{
"individual": "NA12156",
"gene": "CYP2C8",
"truth": "*1/*15",
"call": "*1/*1",
"within_equivalents": true
},
{
"individual": "NA12156",
"gene": "CYP4F2",
"truth": "(*2)/*3",
"call": "*1/*3",
"within_equivalents": false
},
{
"individual": "NA12156",
"gene": "SLCO1B1",
"truth": "*1/*21",
"call": "*1/*37",
"within_equivalents": false
},
{
"individual": "NA12156",
"gene": "UGT1A1",
"truth": "*60/*28",
"call": "*28/*80",
"within_equivalents": false
},
{
"individual": "NA12044",
"gene": "TPMT",
"truth": "*1/*21",
"call": "*1/*1",
"within_equivalents": false
},
{
"individual": "NA07019",
"gene": "CYP1A2",
"truth": "*1A/*1F",
"call": "*1/*15",
"within_equivalents": false
},
{
"individual": "NA07019",
"gene": "CYP2C8",
"truth": "*1/*1",
"call": "*1/*19",
"within_equivalents": false
},
{
"individual": "NA07019",
"gene": "UGT1A1",
"truth": "*1/*60",
"call": "*1/*1",
"within_equivalents": false
},
{
"individual": "NA06991",
"gene": "CYP1A2",
"truth": "*1F/*1F",
"call": "*15/*24",
"within_equivalents": false
},
{
"individual": "NA06991",
"gene": "UGT1A1",
"truth": "*60/*60",
"call": "*1/*1",
"within_equivalents": false
},
{
"individual": "NA06991",
"gene": "VKORC1",
"truth": "AA",
"call": "rs9923231 T/T",
"within_equivalents": false
},
{
"individual": "NA12878",
"gene": "CYP1A2",
"truth": "*1F/*1F",
"call": "*15/*24",
"within_equivalents": false
},
{
"individual": "NA12878",
"gene": "UGT1A1",
"truth": "*60/*28",
"call": "*28/*80",
"within_equivalents": false
},
{
"individual": "NA11993",
"gene": "CYP1A2",
"truth": "*1F/*1F",
"call": "*15/*24",
"within_equivalents": false
},
{
"individual": "NA11993",
"gene": "CYP2A6",
"truth": "*9/*17",
"call": "*15/*17",
"within_equivalents": true
},
{
"individual": "NA11993",
"gene": "SLCO1B1",
"truth": "*1/*17",
"call": "*1/*15",
"within_equivalents": false
},
{
"individual": "NA11993",
"gene": "UGT1A1",
"truth": "(*28 + *60)/*60",
"call": "*28/*80",
"within_equivalents": false
},
{
"individual": "NA12003",
"gene": "CYP1A2",
"truth": "*1A/*1F",
"call": "*15/*24",
"within_equivalents": false
},
{
"individual": "NA12003",
"gene": "CYP2A6",
"truth": "*1/(*8)",
"call": "*1/*1",
"within_equivalents": false
},
{
"individual": "NA12003",
"gene": "UGT1A1",
"truth": "*60/*28",
"call": "*28/*80",
"within_equivalents": false
},
{
"individual": "NA12003",
"gene": "VKORC1",
"truth": "AA",
"call": "rs9923231 T/T",
"within_equivalents": false
},
{
"individual": "NA12003",
"gene": "GSTT1",
"truth": "(*A/*A)",
"call": "*1/*0",
"within_equivalents": false
},
{
"individual": "NA07029",
"gene": "CYP1A2",
"truth": "*1A/*1F",
"call": "*1/*15",
"within_equivalents": false
},
{
"individual": "NA07029",
"gene": "CYP2B6",
"truth": "*6/(*27)",
"call": "*1/*6",
"within_equivalents": false
},
{
"individual": "NA07029",
"gene": "CYP2C19",
"truth": "*8/*17",
"call": "*1/*17",
"within_equivalents": true
},
{
"individual": "NA07029",
"gene": "CYP2C8",
"truth": "*1/*3",
"call": "*19/*3",
"within_equivalents": false
},
{
"individual": "NA07029",
"gene": "UGT1A1",
"truth": "*1/*1",
"call": "*1/*28",
"within_equivalents": true
},
{
"individual": "NA12892",
"gene": "CYP2B6",
"truth": "*1/*1",
"call": "*1/*5",
"within_equivalents": false
},
{
"individual": "NA10846",
"gene": "CYP2C8",
"truth": "*1/*1",
"call": "*1/*19",
"within_equivalents": false
},
{
"individual": "NA07000",
"gene": "CYP2C8",
"truth": "*1/*1",
"call": "*1/*19",
"within_equivalents": false
},
{
"individual": "NA11832",
"gene": "CYP1A2",
"truth": "*1F/*1F",
"call": "*15/*24",
"within_equivalents": false
},
{
"individual": "NA11832",
"gene": "NAT2",
"truth": "*5/(*12)",
"call": "*1/*5",
"within_equivalents": false
},
{
"individual": "NA11832",
"gene": "SLCO1B1",
"truth": "*1/*1",
"call": "*14/*20",
"within_equivalents": false
},
{
"individual": "NA11832",
"gene": "UGT1A1",
"truth": "(*28 + *60)/*60",
"call": "*28/*80",
"within_equivalents": false
},
{
"individual": "NA10838",
"gene": "CYP2D6",
"truth": "*2/*4",
"call": "*1/*4;*2/*4",
"within_equivalents": true
},
{
"individual": "NA10838",
"gene": "SLCO1B1",
"truth": "*1/*1",
"call": "*1/*14",
"within_equivalents": false
},
{
"individual": "NA12813",
"gene": "CYP1A2",
"truth": "*1F/*1L",
"call": "*15/*24",
"within_equivalents": false
},
{
"individual": "NA12813",
"gene": "CYP2C8",
"truth": "*1/*1",
"call": "*1/*19",
"within_equivalents": false
},
{
"individual": "NA12813",
"gene": "CYP2D6",
"truth": "*2/*4",
"call": "*1/*4;*2/*4",
"within_equivalents": true
},
{
"individual": "NA12813",
"gene": "SLCO1B1",
"truth": "*1/*21",
"call": "*1/*20",
"within_equivalents": false
},
{
"individual": "NA12813",
"gene": "UGT1A1",
"truth": "(*28 + *60)/(*28 + *60)",
"call": "*80/*80",
"within_equivalents": false
},
{
"individual": "NA12813",
"gene": "GSTT1",
"truth": "(*A/*A)",
"call": "*1/*0",
"within_equivalents": false
},
{
"individual": "NA11881",
"gene": "CYP2C8",
"truth": "*1/*1",
"call": "*1/*19",
"within_equivalents": false
},
{
"individual": "NA11881",
"gene": "SLCO1B1",
"truth": "*1/(*15)",
"call": "*14/*15",
"within_equivalents": false
}
]
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Build the cyto annotation bundle (spec CYTO-036, IN-054) from public UCSC tables:
# cytoBand (ISCN bands) and ncbiRefSeq (RefSeq transcripts with gene symbols, from NCBI RefSeq).
# Output: simurg-cyto-annot-<build>-<date>-<sha12>.zip with cytoBand.txt.gz, genes.tsv.gz and manifest.json.
# bash scripts/build_annotation_bundle.sh GRCh38|GRCh37 [OUT_DIR]
set -euo pipefail
BUILD=${1:?usage: build_annotation_bundle.sh GRCh38|GRCh37 [OUT_DIR]}
OUT=${2:-data/annotation}
case $BUILD in GRCh38) UCSC=hg38;; GRCh37) UCSC=hg19;; *) echo "unknown build $BUILD"; exit 2;; esac
BASE=https://hgdownload.soe.ucsc.edu/goldenPath/$UCSC/database
W=$(mktemp -d); trap 'rm -rf "$W"' EXIT
mkdir -p "$OUT"
for t in cytoBand ncbiRefSeq; do curl -sSfL -o "$W/$t.txt.gz" "$BASE/$t.txt.gz"; done
curl -sSfL -o "$W/ncbiRefSeq.sql" "$BASE/ncbiRefSeq.sql" || true
# ncbiRefSeq columns: bin name chrom strand txStart txEnd cdsStart cdsEnd exonCount exonStarts exonEnds score name2 ...
zcat "$W/ncbiRefSeq.txt.gz" | awk -F'\t' 'BEGIN{OFS="\t"; print "chrom","start","end","gene","transcript"}
{ c=$3; sub(/^chr/,"",c); if (c ~ /_/) next; print c, $5+1, $6, $13, $2 }' | gzip -9 > "$W/genes.tsv.gz"
DATE=$(date -u +%Y%m%d)
python3 - "$W" "$BUILD" "$UCSC" "$DATE" <<'PY'
import hashlib, json, sys, pathlib
w, build, ucsc, date = pathlib.Path(sys.argv[1]), *sys.argv[2:]
files = {n: hashlib.sha256((w / n).read_bytes()).hexdigest() for n in ("cytoBand.txt.gz", "genes.tsv.gz")}
m = {"schema": "simurg.cyto_annotation/1", "build": build, "created": date,
"sources": [{"name": "UCSC cytoBand", "version": ucsc, "release_date": None},
{"name": "RefSeq (UCSC ncbiRefSeq)", "version": ucsc, "release_date": None}],
"files": files}
(w / "manifest.json").write_text(json.dumps(m, indent=1))
PY
( cd "$W" && zip -q -X bundle.zip manifest.json cytoBand.txt.gz genes.tsv.gz )
SHA=$(sha256sum "$W/bundle.zip" | cut -c1-12)
cp "$W/bundle.zip" "$OUT/simurg-cyto-annot-$BUILD-$DATE-$SHA.zip"
ls -la "$OUT/simurg-cyto-annot-$BUILD-$DATE-$SHA.zip"
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# v2 CN/LOH validation on GSE165845 (360 GSA arrays, 120 1000 Genomes individuals x 3): simurg cn call vs the NYGC
# 30x structural-variant callset (1KGP_3202 GATK-SV/svtools freeze V3) and between replicates.
# Args: <unused data dir> <results dir>. Run with OMNI=0.
set -uo pipefail
OUT=$2
W=/work/cnv; R=$OUT/cnv; mkdir -p $W $R
N=$(nproc)
export DEBIAN_FRONTEND=noninteractive
apt-get install -y -q bcftools tabix gcc > /dev/null
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal > /dev/null 2>&1
export PATH="$HOME/.cargo/bin:$PATH"
VIRTUAL_ENV=$PWD/.venv uv pip install -q ./rust/simurg-core || echo "!! kernels not built (pure Python)"
SV=https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/data_collections/1000G_2504_high_coverage/working/20210124.SV_Illumina_Integration/1KGP_3202.gatksv_svtools_novelins.freeze_V3.wAF.vcf.gz
( curl -sSfL --retry 5 -o $W/sv.vcf.gz $SV && curl -sSfL --retry 5 -o $W/sv.vcf.gz.tbi $SV.tbi \
&& echo "SV callset $(du -h $W/sv.vcf.gz | cut -f1) at $(date -u +%T)" ) > $W/sv.log 2>&1 &
SV_PID=$!
( bash scripts/fetch_data.sh p6 ) > $W/fasta.log 2>&1 &
FASTA_PID=$!
echo "=== data $(date -u +%T) ==="
GSA=https://webdata.illumina.com/downloads/productfiles/global-screening-array/v1-0
mkdir -p $W/gsa && cd $W/gsa
for z in infinium-global-screening-array-24-v1-0-c2-manifest-file-bpm-build38.zip \
infinium-global-screening-array-v1-0-c1-cluster-file.zip; do
curl -sSfL --retry 5 -O "$GSA/$z" && unzip -oq $z
done
cd - > /dev/null
BPM=$W/gsa/GSA-24v1-0_C2.bpm; EGT=$W/gsa/GSA-24v1-0_C1_ClusterFile.egt
.venv/bin/python scripts/p8/prepare.py --tsv scripts/p8/gse165845_samples.tsv --dl $W/geo --out $W/idats --workers 24 \
| tail -2 || exit 1
echo "=== genotype $(date -u +%T) ==="
.venv/bin/simurg genotype --bpm $BPM --egt $EGT --idats $W/idats --out $W/gt --threads $N --quiet
cp $W/idats/samples_map.tsv $W/gt/sample_summary.tsv $R/
echo "=== model $(date -u +%T) ==="
wait $FASTA_PID; tail -1 $W/fasta.log
/usr/bin/time -v .venv/bin/simurg cn build-model --bpm $BPM --fasta data/p6/GRCh38.primary.fa --egt $EGT \
--reference-gtcs $W/gt --max-reference 120 --out $W/gsa1.simurg-cyto 2> $R/model_time.txt | grep -v "GC windows"
grep -E "Elapsed|Maximum resident" $R/model_time.txt
echo "=== cn call $(date -u +%T) ==="
/usr/bin/time -v .venv/bin/simurg cn call --bpm $BPM --model $W/gsa1.simurg-cyto --gtcs $W/gt \
--fasta data/p6/GRCh38.primary.fa --out $W/cn --threads $N --quiet 2> $R/call_time.txt
grep -E "Elapsed|Maximum resident" $R/call_time.txt
.venv/bin/simurg cn annotate --vcfs $W/cn --quiet || echo "annotate failed"
cp $W/cn/cn_sample_summary.tsv $W/cn/cn_call_manifest.json $R/
echo "=== truth $(date -u +%T) ==="
wait $SV_PID; cat $W/sv.log
cut -f3 $W/idats/samples_map.tsv | tail -n +2 | sort -u > $W/inds.txt
bcftools view -S $W/inds.txt --force-samples -i 'INFO/SVTYPE="DEL" || INFO/SVTYPE="DUP"' -Ou $W/sv.vcf.gz \
| bcftools query -i 'GT="alt"' -f '[%CHROM\t%POS\t%INFO/END\t%INFO/SVTYPE\t%SAMPLE\n]' > $W/truth.tsv
wc -l < $W/truth.tsv
.venv/bin/python scripts/v2/cn_validate_truth.py --calls $W/cn --map $W/idats/samples_map.tsv --truth $W/truth.tsv \
--bpm $BPM --out $R/validation
gzip -c $W/truth.tsv > $R/truth.tsv.gz
echo "=== task done $(date -u +%T) ==="
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# v2 methylation QC: numerics vs the sesame oracle (R, MIT) on the public minfiDataEPIC set (3 x NA12878, EPIC v1).
# Args: <unused data dir> <results dir>. Run with OMNI=0.
set -uo pipefail
OUT=$2
R=$OUT/meth; W=/work/meth; mkdir -p $R $W
ARCH=$(uname -m)
echo "=== data $(date -u +%T) ==="
cd $W
curl -sSfL -O https://bioconductor.org/packages/release/data/experiment/src/contrib/minfiDataEPIC_1.38.0.tar.gz \
&& tar -xzf minfiDataEPIC_1.38.0.tar.gz minfiDataEPIC/inst/extdata
curl -sSfL -O https://webdata.illumina.com/downloads/productfiles/methylationEPIC/infinium-methylationepic-v-1-0-b5-manifest-file-csv.zip \
&& unzip -oq infinium-methylationepic-v-1-0-b5-manifest-file-csv.zip
cd - > /dev/null
MAN=$W/infinium-methylationepic-v-1-0-b5-manifest-file.csv
E=$W/minfiDataEPIC/inst/extdata
PREFIXES="$E/200144450018/200144450018_R04C01 $E/200144450019/200144450019_R07C01 $E/200144450021/200144450021_R05C01"
.venv/bin/simurg methyl convert-manifest --manifest $MAN --out $W/mft --quiet
echo "=== sesame (conda) $(date -u +%T) ==="
curl -sSfL -o $W/mf.sh "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-$ARCH.sh" \
&& bash $W/mf.sh -b -p $W/conda > /dev/null
$W/conda/bin/mamba create -y -q -p $W/renv -c conda-forge -c bioconda r-base bioconductor-sesame > $R/conda.log 2>&1 \
|| { echo "!! sesame install failed"; tail -30 $R/conda.log; }
$W/renv/bin/Rscript scripts/v2/sesame_oracle.R $W/mft/probes.tsv $W/mft/controls.tsv $W/sesame $PREFIXES \
> $R/sesame.log 2>&1 || echo "!! sesame run failed"
tail -40 $R/sesame.log
echo "=== compare $(date -u +%T) ==="
.venv/bin/python scripts/v2/methyl_oracle_compare.py --manifest $MAN --sesame $W/sesame --out $R/compare.json $PREFIXES
.venv/bin/simurg methyl qc --manifest $MAN --idats $E --sheet $E/SampleSheet.csv --out $W/qc --threads 0 --quiet
cp $W/qc/methyl_qc_summary.tsv $W/qc/controls_qc.tsv $R/
echo "=== task done $(date -u +%T) ==="
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Build the PGx CN target-region BED (spec PCN-001) from UCSC ncbiRefSeq (GRCh38).
python scripts/pgx/build_cn_regions.py ncbiRefSeq.hg38.txt.gz OUT.bed [--version N]
Regions (0-based half-open BED: chrom start0 end region gene), from the MANE/first-listed transcript:
CYP2D6.p5 2 kb 5' flank of CYP2D6 (upstream of exon 1; the gene is on the minus strand)
CYP2D6.intron.2 CYP2D6 intron 2
CYP2D6.exon.9 CYP2D6 exon 9
CYP2A6.exon.1 CYP2A6 exon 1 (± 500 bp)
CYP2A6.intron.7 CYP2A6 intron 7
GSTM1, GSTT1 (on chr22_KI270879v1_alt, IN-062), UGT2B17, SULT1A1, CYP2E1: whole gene.
Exons and introns are numbered in transcript order (strand-aware). [DB: NCBI RefSeq via UCSC ncbiRefSeq]
"""
from __future__ import annotations
import argparse
import gzip
from pathlib import Path
TRANSCRIPT = {"CYP2D6": "NM_000106", "CYP2A6": "NM_000762", "GSTM1": "NM_000561", "GSTT1": "NM_000853",
"UGT2B17": "NM_001077", "SULT1A1": "NM_001055", "CYP2E1": "NM_000773"} # fmt: skip
FLANK = 2000
PAD = 500
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("refseq", type=Path)
ap.add_argument("out", type=Path)
ap.add_argument("--version", type=int, default=1)
a = ap.parse_args()
tx: dict[str, tuple[str, str, list[int], list[int]]] = {}
with gzip.open(a.refseq, "rt") as fh:
for line in fh:
f = line.rstrip("\n").split("\t")
name, chrom, strand, gene = f[1], f[2], f[3], f[12]
if gene not in TRANSCRIPT or not name.startswith(TRANSCRIPT[gene] + "."):
continue
if "_" in chrom and not (gene == "GSTT1" and chrom == "chr22_KI270879v1_alt"):
continue # primary assembly, except GSTT1 which is only on the alt contig
starts = [int(x) for x in f[9].rstrip(",").split(",")]
ends = [int(x) for x in f[10].rstrip(",").split(",")]
tx.setdefault(gene, (chrom, strand, starts, ends))
missing = set(TRANSCRIPT) - set(tx)
if missing:
raise SystemExit(f"transcripts not found: {sorted(missing)}")
rows: list[tuple[str, int, int, str, str]] = []
def exon(gene: str, k: int) -> tuple[int, int]:
_, strand, s, e = tx[gene]
i = k - 1 if strand == "+" else len(s) - k
return s[i], e[i]
def intron(gene: str, k: int) -> tuple[int, int]:
strand = tx[gene][1]
a_, b_ = exon(gene, k), exon(gene, k + 1)
return (a_[1], b_[0]) if strand == "+" else (b_[1], a_[0])
for gene in ("CYP2D6", "CYP2A6"):
chrom, strand, s, e = tx[gene]
if gene == "CYP2D6":
p5 = (e[-1], e[-1] + FLANK) if strand == "-" else (s[0] - FLANK, s[0])
rows += [(chrom, *p5, "CYP2D6.p5", gene), (chrom, *intron(gene, 2), "CYP2D6.intron.2", gene),
(chrom, *exon(gene, 9), "CYP2D6.exon.9", gene)] # fmt: skip
else:
x1 = exon(gene, 1)
rows += [(chrom, x1[0] - PAD, x1[1] + PAD, "CYP2A6.exon.1", gene),
(chrom, *intron(gene, 7), "CYP2A6.intron.7", gene)] # fmt: skip
for gene in ("GSTM1", "GSTT1", "UGT2B17", "SULT1A1", "CYP2E1"):
chrom, strand, s, e = tx[gene]
rows.append((chrom, s[0], e[-1], gene, gene))
order = {f"chr{i}": i for i in range(1, 23)}
rows.sort(key=lambda r: (order.get(r[0].split("_")[0], 99), r[0], r[1]))
with a.out.open("w") as fh:
fh.write(f"# simurg PGx CN regions v{a.version}, GRCh38, from UCSC ncbiRefSeq (PCN-001)\n")
for r in rows:
fh.write("\t".join(map(str, r)) + "\n")
print(a.out.read_text())
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
# GSA v4 PGx demo (idats_Demo_36) sample identities: best match among the 3202 1000 Genomes 30x individuals
# by genotype concordance on 4647 chr22 SNPs with 1000G AF 0.05-0.95 (next best <= 0.91: relatives)
sample_id individual concordance second_best second_concordance
207895980019_R03C01 NA12875 0.9920 NA12865 0.7038
207895980019_R03C02 NA12156 0.9933 NA10831 0.7021
207895980019_R04C01 NA12156 0.9931 NA10831 0.7027
207895980019_R05C01 NA12044 0.9931 NA10857 0.6969
207895980019_R06C01 NA11992 0.9931 NA10860 0.6848
207895980019_R07C01 NA06993 0.9940 NA06991 0.7209
207895980019_R07C03 NA07019 0.9953 NA07056 0.7051
207895980019_R08C01 NA12239 0.9938 NA10847 0.7022
207895980019_R09C01 NA12716 0.9944 NA12707 0.6896
207895980019_R09C03 NA06991 0.9931 NA06993 0.7215
207895980019_R10C01 NA12878 0.9933 NA12892 0.7022
207895980019_R11C01 NA12760 0.9948 NA12752 0.7171
207895980019_R11C02 NA06985 0.9948 NA06991 0.6996
207895980019_R12C01 NA11993 0.9961 NA10860 0.7168
207895980019_R12C02 NA12003 0.9935 NA10838 0.6998
207895980019_R13C03 NA06994 0.9948 NA07029 0.7114
207895980019_R14C03 NA11993 0.9959 NA10860 0.7163
207895980019_R15C02 NA12003 0.9931 NA10838 0.6993
207895980035_R03C03 NA07029 0.9952 NA07000 0.7237
207895980035_R04C03 NA12892 0.9957 NA12878 0.7005
207895980035_R05C03 NA12248 0.9950 NA10835 0.7143
207895980035_R06C03 NA10846 0.9920 NA12145 0.7056
207895980035_R07C01 NA07000 0.9957 NA07029 0.7231
207895980035_R07C03 NA12155 0.9935 NA10831 0.6903
207895980035_R08C01 NA11832 0.9942 NA10855 0.7252
207895980035_R08C03 NA06993 0.9940 NA06991 0.7205
207895980035_R09C03 NA10838 0.9948 NA12004 0.7007
207895980035_R10C01 NA12813 0.9937 NA07045 0.9103
207895980035_R10C03 NA12249 0.9938 NA10835 0.6858
207895980035_R11C03 NA12812 0.9942 NA12801 0.7061
207895980035_R12C03 NA11881 0.9935 NA10859 0.7138
207895980035_R13C01 NA11995 0.9953 NA10861 0.6857
207895980035_R13C03 NA12248 0.9950 NA10835 0.7145
207895980035_R14C01 NA12891 0.9942 NA12878 0.6998
207895980035_R15C03 NA12043 0.9946 NA10857 0.7162
207895980042_R15C02 NA11832 0.9942 NA10855 0.7253
1 # GSA v4 PGx demo (idats_Demo_36) sample identities: best match among the 3202 1000 Genomes 30x individuals
2 # by genotype concordance on 4647 chr22 SNPs with 1000G AF 0.05-0.95 (next best <= 0.91: relatives)
3 sample_id individual concordance second_best second_concordance
4 207895980019_R03C01 NA12875 0.9920 NA12865 0.7038
5 207895980019_R03C02 NA12156 0.9933 NA10831 0.7021
6 207895980019_R04C01 NA12156 0.9931 NA10831 0.7027
7 207895980019_R05C01 NA12044 0.9931 NA10857 0.6969
8 207895980019_R06C01 NA11992 0.9931 NA10860 0.6848
9 207895980019_R07C01 NA06993 0.9940 NA06991 0.7209
10 207895980019_R07C03 NA07019 0.9953 NA07056 0.7051
11 207895980019_R08C01 NA12239 0.9938 NA10847 0.7022
12 207895980019_R09C01 NA12716 0.9944 NA12707 0.6896
13 207895980019_R09C03 NA06991 0.9931 NA06993 0.7215
14 207895980019_R10C01 NA12878 0.9933 NA12892 0.7022
15 207895980019_R11C01 NA12760 0.9948 NA12752 0.7171
16 207895980019_R11C02 NA06985 0.9948 NA06991 0.6996
17 207895980019_R12C01 NA11993 0.9961 NA10860 0.7168
18 207895980019_R12C02 NA12003 0.9935 NA10838 0.6998
19 207895980019_R13C03 NA06994 0.9948 NA07029 0.7114
20 207895980019_R14C03 NA11993 0.9959 NA10860 0.7163
21 207895980019_R15C02 NA12003 0.9931 NA10838 0.6993
22 207895980035_R03C03 NA07029 0.9952 NA07000 0.7237
23 207895980035_R04C03 NA12892 0.9957 NA12878 0.7005
24 207895980035_R05C03 NA12248 0.9950 NA10835 0.7143
25 207895980035_R06C03 NA10846 0.9920 NA12145 0.7056
26 207895980035_R07C01 NA07000 0.9957 NA07029 0.7231
27 207895980035_R07C03 NA12155 0.9935 NA10831 0.6903
28 207895980035_R08C01 NA11832 0.9942 NA10855 0.7252
29 207895980035_R08C03 NA06993 0.9940 NA06991 0.7205
30 207895980035_R09C03 NA10838 0.9948 NA12004 0.7007
31 207895980035_R10C01 NA12813 0.9937 NA07045 0.9103
32 207895980035_R10C03 NA12249 0.9938 NA10835 0.6858
33 207895980035_R11C03 NA12812 0.9942 NA12801 0.7061
34 207895980035_R12C03 NA11881 0.9935 NA10859 0.7138
35 207895980035_R13C01 NA11995 0.9953 NA10861 0.6857
36 207895980035_R13C03 NA12248 0.9950 NA10835 0.7145
37 207895980035_R14C01 NA12891 0.9942 NA12878 0.6998
38 207895980035_R15C03 NA12043 0.9946 NA10857 0.7162
39 207895980042_R15C02 NA11832 0.9942 NA10855 0.7253
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Derive a PGx probe mask from array-vs-WGS concordance (spec PGX-010) for the individuals of a GSA PGx batch.
python scripts/pgx/derive_masks.py --db DB.sqlite --vcfs VCF_DIR --identity demo36_identity.tsv \
--exclude-individuals getrm.txt --manifest NAME --out MASK.tsv [--min-concordance 0.95]
Truth: the 1000 Genomes 30x phased panel (NYGC, 3202 samples), fetched per gene window over HTTPS with pysam (MIT;
a script-only dependency: ``uv pip install pysam``). For every PGx-DB defining variant the array record(s) at that
site are compared with the truth alt dosage; records whose concordance is below the threshold (≥ 5 comparisons)
are masked by their probe names. Individuals listed in ``--exclude-individuals`` (the validation set) are not used,
so the mask stays independent of the star-allele validation. The spec asks for ≥ 100 samples; this batch has fewer
(recorded as K-029).
"""
from __future__ import annotations
import argparse
import csv
import gzip
import sqlite3
from collections import defaultdict
from pathlib import Path
PANEL = (
"https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/data_collections/1000G_2504_high_coverage/working/"
"20220422_3202_phased_SNV_INDEL_SV/1kGP_high_coverage_Illumina.{c}.filtered.SNV_INDEL_SV_phased_panel.vcf.gz"
)
def main() -> int:
import pysam
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--db", required=True, type=Path)
ap.add_argument("--vcfs", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--exclude-individuals", type=Path)
ap.add_argument("--manifest", required=True)
ap.add_argument("--min-concordance", type=float, default=0.95)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--cache", type=Path, default=Path("data/pgx/cache"), help="where pysam keeps remote .tbi files")
a = ap.parse_args()
ident = {}
with a.identity.open() as fh:
for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t"):
ident[r["sample_id"]] = r["individual"]
excl = set(a.exclude_individuals.read_text().split()) if a.exclude_individuals else set()
use = {s: i for s, i in ident.items() if i not in excl}
con = sqlite3.connect(a.db)
# the highest population frequency among the alleles that carry each variant (CPIC frequencies)
vfreq: dict[tuple[str, int, str, str], float] = defaultdict(float)
for c, p, r, al, f in con.execute(
"SELECT v.chrom, v.pos, v.ref, v.alt, MAX(f.freq) FROM allele_variants v JOIN frequencies f "
"ON f.gene = v.gene AND f.allele = v.allele GROUP BY v.chrom, v.pos, v.ref, v.alt"
):
vfreq[(c, p, r, al)] = float(f or 0.0)
keys = sorted({(c, p, r, al, g) for g, c, p, r, al in con.execute(
"SELECT gene, chrom, pos, ref, alt FROM allele_variants")}) # fmt: skip
# array dosages per (chrom, pos, ref, alt) and record ID
wanted = defaultdict(set)
for c, p, *_ in keys:
wanted[c].add(p)
arr: dict[tuple[str, int, str, str], dict[str, dict[str, int]]] = defaultdict(lambda: defaultdict(dict))
gsd: dict[tuple[str, int, str, str, str, str], float] = {}
for sid in sorted(use):
path = a.vcfs / f"{sid}.snv.vcf.gz"
if not path.exists():
continue
with gzip.open(path, "rt") as fh:
for ln in fh:
if ln[0] == "#":
continue
c, p, rid, ref, alts = ln.split("\t", 5)[:5]
if int(p) not in wanted.get(c, ()):
continue
sample = ln.rstrip("\n").split("\t")[9].split(":")
gt = sample[0].replace("|", "/").split("/")
if "." in gt:
continue
gs_v = float(sample[1]) if len(sample) > 1 and sample[1] not in (".", "") else float("nan")
for ai, alt in enumerate(alts.split(","), start=1):
arr[(c, int(p), ref, alt)][rid][sid] = sum(int(x) == ai for x in gt)
gsd[(c, int(p), ref, alt, rid, sid)] = gs_v
# truth per gene window
truth: dict[tuple[str, int, str, str], dict[str, int]] = {}
by_gene = defaultdict(list)
for c, p, r, al, g in keys:
by_gene[(g, c)].append((p, r, al))
inds = sorted(set(use.values()))
import os
a.out = a.out.resolve()
a.cache.mkdir(parents=True, exist_ok=True)
os.chdir(a.cache) # pysam downloads the remote tabix indexes into the working directory
for (g, c), vs in sorted(by_gene.items()):
core = c.removeprefix("chr")
chrom = "chrM" if core in ("MT", "M") else f"chr{core}"
if chrom == "chrM":
continue
try:
vf = pysam.VariantFile(PANEL.format(c=chrom))
except (OSError, ValueError):
print(f"{g} {chrom}: panel file not available, skipped")
continue
samples = [s for s in inds if s in vf.header.samples]
vf.subset_samples(samples)
lo, hi = min(p for p, _, _ in vs) - 10, max(p + len(r) for p, r, _ in vs) + 10
want = {(p, r, al) for p, r, al in vs}
seen = set()
for rec in vf.fetch(chrom, lo, hi):
for ai, alt in enumerate(rec.alts or (), start=1):
k = (rec.pos, rec.ref, alt)
if k in want:
truth[(c, *k)] = {s: sum(x == ai for x in rec.samples[s]["GT"] if x is not None) for s in samples}
seen.add(k)
for p, r, al in want - seen:
# absent from the (filtered) panel: homozygous reference only for a rare variant; a common one was
# most likely filtered out (e.g. CYP2B6 rs2279343 next to CYP2B7) and is not assessed
if vfreq.get((c, p, r, al), 0.0) < 0.05:
truth[(c, p, r, al)] = dict.fromkeys(samples, 0)
print(f"{g} {chrom}: {len(want)} variant(s), {len(seen)} in the panel")
rows = []
stats = [0, 0]
bins = [0.0, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 1.01]
calib = [[0, 0] for _ in bins[:-1]] # [discordant, n] per GS bin over unmasked records (error-model calibration)
for k, recs in sorted(arr.items()):
t = truth.get(k)
if t is None:
continue
for rid, dos in sorted(recs.items()):
pairs = [(d, t[use[s]]) for s, d in dos.items() if use[s] in t]
if len(pairs) < 5:
continue
conc = sum(x == y for x, y in pairs) / len(pairs)
stats[1] += 1
if conc >= a.min_concordance:
for s_, d in dos.items():
if use[s_] not in t:
continue
gv = gsd.get((*k, rid, s_), float("nan"))
for bi in range(len(bins) - 1):
if bins[bi] <= gv < bins[bi + 1]:
calib[bi][0] += d != t[use[s_]]
calib[bi][1] += 1
if conc < a.min_concordance:
stats[0] += 1
for probe in rid.split(","):
rows.append((probe, f"concordance {conc:.2f} vs 1000G 30x at {k[0]}:{k[1]} {k[2]}>{k[3]} "
f"(n={len(pairs)})", a.manifest)) # fmt: skip
with a.out.open("w") as fh:
fh.write(f"# PGx probe mask for {a.manifest} (PGX-010): array vs 1000G 30x, {len(inds)} individuals, "
f"threshold {a.min_concordance}\n") # fmt: skip
for r in sorted(set(rows)):
fh.write("\t".join(r) + "\n")
print(f"masked {stats[0]} of {stats[1]} assessed record(s); {len(set(rows))} probe name(s) -> {a.out}")
for bi, (dsc, n) in enumerate(calib):
if n:
print(f"GS [{bins[bi]:.2f}, {bins[bi + 1]:.2f}): discordance {dsc}/{n} = {dsc / n:.4f} (unmasked records)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Download the public PGx sources for `simurg pgx db build` (spec PGX-001) into a dated folder.
# bash scripts/pgx/fetch_sources.sh [OUT_DIR] (default data/pgx/sources)
# CPIC (CC0): tables from the CPIC API (https://api.cpicpgx.org, PostgREST) as JSON; the newest change-log date is
# recorded as the data version. PharmVar (https://www.pharmvar.org; cite Gaedigk et al. 2018 CPT 103:399): per-gene
# zips (haplotype VCFs, GRCh37/GRCh38) for the star-allele genes CPIC does not define. Never committed (data/ is
# ignored); the DB records each source's version and file hashes.
set -euo pipefail
OUT=${1:-data/pgx/sources}
DATE=$(date -u +%Y%m%d)
D="$OUT/cpic-$DATE"
mkdir -p "$D"
API=https://api.cpicpgx.org/v1
for t in gene allele allele_definition allele_location_value sequence_location gene_result gene_result_lookup \
gene_result_diplotype population change_log; do
curl -sSf --retry 3 -H "Accept: application/json" "$API/$t" -o "$D/$t.json"
python3 -c "import json,sys; print(sys.argv[1], len(json.load(open(sys.argv[2]))))" "$t" "$D/$t.json"
done
python3 - "$D" <<'PY'
import hashlib, json, pathlib, sys
d = pathlib.Path(sys.argv[1])
log = json.loads((d / "change_log.json").read_text())
ver = max(e["date"] for e in log if e.get("date"))
files = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(d.glob("*.json")) if p.name != "source.json"}
(d / "source.json").write_text(json.dumps({"name": "CPIC", "version": ver, "retrieved": d.name.split("-")[1],
"url": "https://api.cpicpgx.org/v1", "license": "CC0-1.0",
"files": files}, indent=1))
print("CPIC data version", ver)
PY
# ---- PharmVar: star-allele genes without CPIC definitions
PV="$OUT/pharmvar-$DATE"
mkdir -p "$PV"
PV_GENES=${PV_GENES:-"CYP1A2 CYP2A6 CYP2A13 CYP2C8 CYP3A4"} # the PharmVar genes CPIC does not define (2026-09)
for g in $PV_GENES; do
if curl -sSf --retry 3 -o "$PV/$g.zip" \
"https://www.pharmvar.org/get-download-file?name=$g&refSeq=ALL&fileType=zip&version=current"; then
echo "PharmVar $g $(unzip -l "$PV/$g.zip" | awk 'NR==4{print $4}' | cut -d/ -f1)"
else
echo "PharmVar $g: not available"; rm -f "$PV/$g.zip"
fi
done
python3 - "$PV" <<'PY'
import hashlib, json, pathlib, re, sys, zipfile
d = pathlib.Path(sys.argv[1])
vers = set()
for z in sorted(d.glob("*.zip")):
top = zipfile.ZipFile(z).namelist()[0].split("/")[0]
m = re.search(r"-(\d+\.\d+\.\d+)$", top)
if m:
vers.add(m.group(1))
files = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(d.glob("*.zip"))}
(d / "source.json").write_text(json.dumps({"name": "PharmVar", "version": ",".join(sorted(vers)),
"retrieved": d.name.split("-")[1], "url": "https://www.pharmvar.org",
"license": "PharmVar terms of use (cite Gaedigk et al. 2018)",
"files": files}, indent=1))
print("PharmVar version", ",".join(sorted(vers)))
PY
# ---- ClinPGx (formerly PharmGKB; CC BY-SA 4.0): clinical-annotation variants (variant-based genes, genotype only)
CP="$OUT/clinpgx-$DATE"
mkdir -p "$CP"
curl -sSfL --retry 3 -o "$CP/clinicalVariants.zip" "https://api.clinpgx.org/v1/download/file/data/clinicalVariants.zip"
python3 - "$CP" <<'PY'
import hashlib, json, pathlib, sys, zipfile
d = pathlib.Path(sys.argv[1])
z = zipfile.ZipFile(d / "clinicalVariants.zip")
created = next((n.split("_", 1)[1].rsplit(".", 1)[0] for n in z.namelist() if n.startswith("CREATED_")), "")
(d / "source.json").write_text(json.dumps({"name": "ClinPGx clinical variants", "version": created,
"retrieved": d.name.split("-")[1], "url": "https://www.clinpgx.org",
"license": "CC BY-SA 4.0",
"files": {"clinicalVariants.zip": hashlib.sha256(
(d / "clinicalVariants.zip").read_bytes()).hexdigest()}}, indent=1))
print("ClinPGx clinical variants", created)
PY
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Extract GeT-RM consensus diplotypes (CDC Genetic Testing Reference Material program, consolidated PGx/HLA table,
public) for the individuals of an identity table.
python scripts/pgx/getrm_truth.py --xlsx Consolidated_PGx-HLA_table_1-22-25-V4.xlsx \
--identity scripts/pgx/demo36_identity.tsv --out getrm_truth.tsv
Writes ``individual, gene, diplotype`` (the table's text, trimmed) for the star-allele genes.
"""
from __future__ import annotations
import argparse
import csv
from pathlib import Path
GENES = ("CYP2B6", "CYP2C9", "CYP2C19", "CYP2D6", "CYP3A4", "CYP3A5", "CYP4F2", "NAT2", "NUDT15", "SLCO1B1", "TPMT",
"UGT1A1", "DPYD", "CYP2A6", "CYP1A2", "CYP2C8", "GSTM1", "GSTT1", "UGT2B17") # fmt: skip
def main() -> int:
import openpyxl
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--xlsx", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
with a.identity.open() as fh:
inds = {r["individual"] for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t")}
wb = openpyxl.load_workbook(a.xlsx, read_only=True)
rows = list(wb[wb.sheetnames[0]].iter_rows(values_only=True))
head = [str(h or "").split("\n")[0].strip() for h in rows[1]]
col = {g: head.index(g) for g in GENES if g in head}
vk = next((i for i, h in enumerate(rows[1]) if str(h or "").startswith("VKORC1") and "rs9923231" in str(h)
and "Reference" not in str(h)), None) # fmt: skip
if vk is not None:
col["VKORC1"] = vk # c.-1639G>A genotype, e.g. "GA"
out = []
for r in rows[2:]:
if not r or not r[1] or str(r[1]).strip() not in inds:
continue
for g, i in col.items():
v = r[i]
if v is not None and str(v).strip():
out.append((str(r[1]).strip(), g, " ".join(str(v).split())))
with a.out.open("w") as fh:
fh.write("individual\tgene\tdiplotype\n")
for t in sorted(out):
fh.write("\t".join(t) + "\n")
print(f"{len({t[0] for t in out})} individual(s), {len(out)} gene call(s) -> {a.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Replicate concordance of `pgx star-allele call`: the top genotype of every gene compared between the replicate
arrays of one individual (PGX-029 reproducibility on real replicates).
python scripts/pgx/replicate_concordance.py --calls STAR_DIR/pgx_calls.json --identity identity.tsv --out rep.json
"""
from __future__ import annotations
import argparse
import csv
import itertools
import json
from collections import defaultdict
from pathlib import Path
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--calls", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
with a.identity.open() as fh:
ident = {r["sample_id"]: r["individual"] for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")),
delimiter="\t")} # fmt: skip
calls: dict[str, dict[str, dict[str, str]]] = defaultdict(dict)
for s in json.loads(a.calls.read_text())["samples"]:
ind = ident.get(s["sample_id"])
if ind:
calls[ind][s["sample_id"]] = {g["gene"]: (g["candidates"][0]["genotype"] if g["candidates"] else "")
for g in s["genes"]} # fmt: skip
per_gene: dict[str, list[int]] = defaultdict(lambda: [0, 0])
discord = []
for ind, reps in sorted(calls.items()):
for (_s1, c1), (_s2, c2) in itertools.combinations(sorted(reps.items()), 2):
for gene in sorted(set(c1) & set(c2)):
ok = c1[gene] == c2[gene]
per_gene[gene][0] += ok
per_gene[gene][1] += 1
if not ok:
discord.append({"individual": ind, "gene": gene, "a": c1[gene], "b": c2[gene]})
tot = [sum(v[0] for v in per_gene.values()), sum(v[1] for v in per_gene.values())]
out = {"pairs_by_gene": {g: {"concordant": v[0], "pairs": v[1]} for g, v in sorted(per_gene.items())},
"total": {"concordant": tot[0], "pairs": tot[1], "rate": round(tot[0] / tot[1], 4) if tot[1] else None},
"discordant": discord} # fmt: skip
a.out.write_text(json.dumps(out, indent=1))
print(json.dumps(out["total"]), f"{len(discord)} discordant gene pair(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Validate `simurg pgx copy-number call` against the NYGC 30x SV callset (1KGP_3202 freeze V3) — PCN-017.
python scripts/pgx/validate_cn_truth.py --calls CALL_DIR --identity scripts/pgx/demo36_identity.tsv \
--sv 1KGP_3202.sv_freeze_V3.vcf.gz --bcftools BCFTOOLS --out summary.json
Truth per region = RD_CN of one SV record per region (below, chosen as the record spanning the whole region; for
GSTM1 the read-depth DUP/CNV record, whose RD_CN gives 0/1/2 in Hardy–Weinberg proportions — the DEL record there
reports CN 1 for 27/30 individuals). CYP2D6 sub-regions share one gene-level record, so exon-9 conversions (*36) and
2D7-2D6 hybrids (*13, *68) legitimately differ from it. GSTT1 lies on an alt contig with no SV calls: only its CN
distribution is reported. Replicate arrays of one individual are counted once (the first array).
"""
from __future__ import annotations
import argparse
import collections
import csv
import json
import subprocess
from pathlib import Path
TRUTH_RECORD = {"GSTM1": "HGSV_9691", "UGT2B17": "HGSV_63272", "SULT1A1": "HGSV_207805",
"CYP2A6.intron.7": "HGSV_232737", "CYP2A6.exon.1": "HGSV_232737", "CYP2D6.exon.9": "HGSV_247317",
"CYP2D6.intron.2": "HGSV_247317", "CYP2D6.p5": "HGSV_247317"} # fmt: skip
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--calls", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--sv", required=True)
ap.add_argument("--bcftools", default="bcftools")
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
ident = {}
with a.identity.open() as fh:
for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t"):
ident[r["sample_id"]] = r["individual"]
calls: dict[str, dict[str, str]] = {}
with (a.calls / "pgx_cn_sample_summary.tsv").open() as fh:
for r in csv.DictReader(fh, delimiter="\t"):
ind = ident.get(r["sample_id"])
if ind and ind not in calls:
calls[ind] = {k[3:]: v for k, v in r.items() if k.startswith("cn_")}
ids = sorted(set(TRUTH_RECORD.values()))
q = subprocess.run([a.bcftools, "query", "-H", "-s", ",".join(sorted(calls)), "--force-samples", "-i",
"ID=@/dev/stdin", "-f", "%ID[\t%RD_CN]\n", a.sv], input="\n".join(ids), capture_output=True,
text=True, check=True).stdout.splitlines() # fmt: skip
samples = [h.split("]")[1].split(":")[0] for h in q[0].split("\t")[1:]]
truth = {ln.split("\t")[0]: dict(zip(samples, ln.split("\t")[1:], strict=True)) for ln in q[1:]}
res: dict[str, dict[str, object]] = {}
tot = [0, 0]
for region, rid in TRUTH_RECORD.items():
pairs = [(truth[rid][ind], c[region]) for ind, c in calls.items() if ind in truth.get(rid, {})]
pairs = [(t, c) for t, c in pairs if t not in (".", "")]
ok = sum(t == c for t, c in pairs)
tot[0] += ok
tot[1] += len(pairs)
res[region] = {"record": rid, "concordant": ok, "n": len(pairs),
"rate": round(ok / len(pairs), 4) if pairs else None,
"truth_to_call_mismatches": dict(collections.Counter(
f"{t}->{c}" for t, c in pairs if t != c))} # fmt: skip
summary = {"individuals": len(calls), "regions": res, "overall": {"concordant": tot[0], "n": tot[1],
"rate": round(tot[0] / tot[1], 4) if tot[1] else None},
"gstt1_cn_distribution": dict(sorted(collections.Counter(
c.get("GSTT1", ".") for c in calls.values()).items()))} # fmt: skip
a.out.write_text(json.dumps(summary, indent=1))
print(json.dumps(summary, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Compare `simurg pgx star-allele call` with GeT-RM consensus diplotypes (PGX-032).
python scripts/pgx/validate_star_getrm.py --calls STAR_DIR/pgx_calls.json --truth getrm_truth.tsv \
--identity scripts/pgx/demo36_identity.tsv --out summary.json
GeT-RM notation is normalised per allele: "*2(*35)" / "*2 (*35)" accept *2 or *35; a trailing sub-allele letter
("*1F") also matches the core allele ("*1") when the call has no letter; "(*68)+*4" tandems and "xN" duplications
reduce to the core allele (structural forms are reported separately); "(*28 + *60)" keeps the star alleles the DB
defines. "A/B or C/D" accepts either. UGT1A1 and SLCO1B1 are reported but left out of the total: GeT-RM uses pre-2023
names (UGT1A1 *60, SLCO1B1 *1B/*17/*21) that the CPIC definitions no longer carry. A sample's gene counts as **top**
concordant when the top solution matches, and as **equivalent**
concordant when the truth is among the array-equivalent members of any feasible solution. Replicate arrays of one
individual are counted once (the first).
"""
from __future__ import annotations
import argparse
import csv
import json
import re
from collections import defaultdict
from pathlib import Path
def truth_options(text: str) -> list[set[str]] | None:
"""Per haplotype, the set of acceptable core alleles; None when the text is not a diplotype."""
t = text.replace(" ", "")
if "/" not in t:
return None
out = []
for side in t.split("/", 1):
opts = set(re.findall(r"\*\d+[A-Z]?", side))
if not opts:
return None
cores = {re.sub(r"x\d+|xN", "", o) for o in opts}
cores |= {re.sub(r"[A-Z]$", "", o) for o in cores}
if "+" in side: # tandem / hybrid: the non-parenthesised last unit is the core allele
last = re.findall(r"\*\d+[A-Z]?", side.split("+")[-1])
cores |= set(last)
out.append(cores)
return out
def _gstm1(t: str) -> int: # GeT-RM GSTM1: DEL/DEL = no copy; *A/*B assays do not see a heterozygous deletion
return 0 if t.replace(" ", "").upper() == "DEL/DEL" else 1
def _gstt1(t: str) -> int: # "0" null, "(*A/0)" one null allele, "(*A/*A)" two copies
t = t.replace(" ", "")
return 0 if t in ("0", "0/0") else (1 if "/0" in t or "0/" in t else 2)
def _ugt2b17(t: str) -> int: # *2 = whole-gene deletion
return 2 - t.count("*2")
CN_TRUTH = {"GSTM1": _gstm1, "GSTT1": _gstt1, "UGT2B17": _ugt2b17} # capped: GSTM1 at 1, the others at 2
# GeT-RM uses older names or definitions than the current CPIC/PharmVar ones: UGT1A1 *60 and SLCO1B1 *1B/*17/*21
# (pre-2023), CYP1A2 lettered alleles (*1F …), CYP2C8 *19 (PharmVar now defines rs2071426 alone as *19; GeT-RM *1)
LEGACY_NOMENCLATURE = {"UGT1A1", "SLCO1B1", "CYP1A2", "CYP2C8"}
def truth_alternatives(text: str) -> list[list[set[str]]]:
"""GeT-RM "A/B or C/D" → one option list per alternative diplotype."""
alts = [truth_options(x) for x in re.split(r"\bor\b", text)]
return [x for x in alts if x is not None]
def matches(call: tuple[str, str], truth: list[list[set[str]]]) -> bool:
a, b = (re.sub(r"x\d+$", "", x.split("+")[-1]) for x in call) # tandem "*68+*4" → core "*4"
return any((a in t[0] and b in t[1]) or (a in t[1] and b in t[0]) for t in truth)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--calls", required=True, type=Path)
ap.add_argument("--truth", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--all-genes", action="store_true",
help="truth with the same definitions (wgs_star_truth.py): no legacy-name exclusions") # fmt: skip
ap.add_argument("--individuals", type=Path, help="only these individuals (one per line)")
ap.add_argument("--coverage", type=Path, help="pgx_coverage.tsv of the array: also report array-resolvable "
"concordance (both diplotypes projected onto the alleles the array can tell apart)") # fmt: skip
a = ap.parse_args()
if a.all_genes:
LEGACY_NOMENCLATURE.clear()
only = set(a.individuals.read_text().split()) if a.individuals else None
proj: dict[tuple[str, str], str] = {}
refs: dict[str, str] = {}
if a.coverage:
with a.coverage.open() as fh:
for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t"):
if r["allele_kind"] == "reference":
refs[r["gene"]] = r["allele"]
st = r["status"]
proj[(r["gene"], r["allele"])] = st.split(":", 1)[1] if st.startswith("equivalent_to:") else (
r["allele"] if st == "callable" else "") # fmt: skip
def project(gene: str, allele: str) -> str:
core = re.sub(r"x\d+$|xN$", "", allele.split("+")[-1])
v = proj.get((gene, core), core)
if v:
return v
ref = refs.get(gene, core) # not covered: indistinguishable from the reference's class
return proj.get((gene, ref)) or ref
with a.identity.open() as fh:
ident = {r["sample_id"]: r["individual"] for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")),
delimiter="\t")} # fmt: skip
truth: dict[tuple[str, str], str] = {}
with a.truth.open() as fh:
for r in csv.DictReader(fh, delimiter="\t"):
if r.get("flag") == "incomplete" or (only is not None and r["individual"] not in only):
continue
truth[(r["individual"], r["gene"])] = r["diplotype"]
calls = json.loads(a.calls.read_text())
per_gene: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
detail = []
seen = set()
for s in calls["samples"]:
ind = ident.get(s["sample_id"])
if ind is None or ind in seen:
continue
seen.add(ind)
for g in s["genes"]:
t = truth.get((ind, g["gene"]))
if t is None:
continue
st = per_gene[g["gene"]]
if g["gene"] in CN_TRUTH: # whole-gene deletion genes: compare copy-number classes (PGX-006)
want_cn = CN_TRUTH[g["gene"]](t)
cn = g.get("copy_number")
got_cn = None if cn is None else (min(cn, 2) if g["gene"] != "GSTM1" else min(cn, 1))
st["n"] += 1
ok = want_cn is not None and want_cn == got_cn
st["top"] += ok
st["equivalent"] += ok
if not ok:
detail.append({"individual": ind, "gene": g["gene"], "truth": t, "call": g["genotype"],
"within_equivalents": False}) # fmt: skip
continue
if g["gene"] == "VKORC1": # c.-1639G>A (minus strand) = rs9923231 C>T: count A = count T
want = t.count("A")
got = g["genotype"].split("/")[-1].count("T") if g["genotype"] != "Reference" else 0
st["n"] += 1
st["top"] += want == got
st["equivalent"] += want == got
if want != got:
detail.append({"individual": ind, "gene": "VKORC1", "truth": t, "call": g["genotype"],
"within_equivalents": False}) # fmt: skip
continue
opts = truth_alternatives(t)
if not opts:
st["not_comparable"] += 1
continue
st["n"] += 1
top = g["candidates"][0]["genotype"].split("/") if g["candidates"] else ["?", "?"]
ok_top = len(top) == 2 and matches((top[0], top[1]), opts)
feas = [c for c in g["candidates"] if c["genotype"] in g["feasible_solutions"]]
ok_eq = ok_top or any(matches(tuple(e.split("/")[:2]), opts) for c in feas for e in c["equivalents"]
if len(e.split("/")) >= 2) # fmt: skip
st["top"] += ok_top
st["equivalent"] += ok_eq
if a.coverage and len(top) == 2 and len(opts) == 1 and all(len(x) == 1 for x in opts[0]):
tt = sorted(project(g["gene"], next(iter(x))) for x in opts[0])
cc = sorted(project(g["gene"], x) for x in top)
st["array_resolvable_n"] += 1
st["array_resolvable"] += tt == cc
if not ok_top:
detail.append({"individual": ind, "gene": g["gene"], "truth": t, "call": g["genotype"],
"within_equivalents": ok_eq}) # fmt: skip
comparable = {k: v for k, v in per_gene.items() if k not in LEGACY_NOMENCLATURE}
tot_ar = [
sum(v.get("array_resolvable", 0) for v in comparable.values()),
sum(v.get("array_resolvable_n", 0) for v in comparable.values()),
]
summary = {"individuals": len(seen), "genes": {k: dict(v) for k, v in sorted(per_gene.items())},
"array_resolvable_total": {"concordant": tot_ar[0], "n": tot_ar[1]},
"legacy_nomenclature_genes": sorted(LEGACY_NOMENCLATURE),
"total": {k: sum(v.get(k, 0) for v in comparable.values()) for k in ("n", "top", "equivalent")},
"discordant": detail} # fmt: skip
a.out.write_text(json.dumps(summary, indent=1))
for g, v in sorted(per_gene.items()):
print(f"{g:8s} top {v['top']}/{v['n']} within equivalents {v['equivalent']}/{v['n']}"
+ (f" (not comparable {v['not_comparable']})" if v.get("not_comparable") else "")
+ (f" array-resolvable {v.get('array_resolvable', 0)}/{v.get('array_resolvable_n', 0)}"
if a.coverage else "")
+ (" [legacy nomenclature]" if g in LEGACY_NOMENCLATURE else "")) # fmt: skip
print("total (excluding legacy-nomenclature genes)", summary["total"])
if a.coverage:
print("array-resolvable total", summary["array_resolvable_total"])
return 0
if __name__ == "__main__":
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Star-allele truth from 1000 Genomes 30x phased genotypes with the PGx DB definitions (SNV/indel alleles only).
python scripts/pgx/wgs_star_truth.py --db DB.sqlite --individuals ids.txt --out wgs_truth.tsv [--genes G,G]
For every star-allele gene of the DB the defining variants are fetched from the NYGC phased panel (pysam over HTTPS,
one window per gene; ``uv pip install pysam``). Each phased haplotype gets the allele whose defining variants it
carries completely and that explains most of the haplotype's carried variants — its defining ones plus those at its
wildcard (IUPAC) positions, so a *4 haplotype carrying the *10 variants stays *4 (ties: more defining variants,
higher CPIC/PharmVar frequency, natural order); a haplotype carrying none is the reference allele. A variant absent
from the filtered panel counts as reference only when rare (max allele frequency < 5 %); a gene with a common
defining variant missing from the panel is written with ``incomplete`` (not comparable). Structural alleles (CYP2D6
*5, xN, hybrids; CYP2A6 *4) are invisible to this truth: the diplotypes are SNV-level only.
"""
from __future__ import annotations
import argparse
import os
import re
import sqlite3
from collections import defaultdict
from pathlib import Path
PANEL = (
"https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/data_collections/1000G_2504_high_coverage/working/"
"20220422_3202_phased_SNV_INDEL_SV/1kGP_high_coverage_Illumina.{c}.filtered.SNV_INDEL_SV_phased_panel.vcf.gz"
)
def natural(a: str) -> tuple[object, ...]:
return tuple(int(x) if x.isdigit() else x for x in re.split(r"(\d+)", a))
def main() -> int:
import pysam
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--db", required=True, type=Path)
ap.add_argument("--individuals", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--genes", default="")
ap.add_argument("--cache", type=Path, default=Path("data/pgx/cache"))
a = ap.parse_args()
inds = sorted(set(a.individuals.read_text().split()))
out_path = a.out.resolve()
con = sqlite3.connect(a.db)
genes = [g for (g,) in con.execute("SELECT gene FROM genes WHERE result_basis = 'star_allele' ORDER BY gene")]
if a.genes:
genes = [g for g in genes if g in a.genes.split(",")]
a.cache.mkdir(parents=True, exist_ok=True)
os.chdir(a.cache)
rows = []
for gene in genes:
q_alle = "SELECT allele FROM alleles WHERE gene = ? AND defined = 1 AND structural = 0"
alle = [x for (x,) in con.execute(q_alle, (gene,))]
q_ref = "SELECT allele FROM alleles WHERE gene = ? AND kind = 'reference'"
ref_allele = next((x for (x,) in con.execute(q_ref, (gene,))), "*1")
defs: dict[str, set[tuple[int, str, str]]] = defaultdict(set)
chrom = None
loc_keys: dict[int, set[tuple[int, str, str]]] = defaultdict(set)
q_var = "SELECT allele, chrom, pos, ref, alt, location_id FROM allele_variants WHERE gene = ?"
for allele, c, p, r, al, lid in con.execute(q_var, (gene,)):
defs[allele].add((p, r, al))
loc_keys[lid].add((p, r, al))
chrom = c
wild: dict[str, set[tuple[int, str, str]]] = defaultdict(set)
for allele, lid in con.execute("SELECT allele, location_id FROM allele_wildcards WHERE gene = ?", (gene,)):
wild[allele] |= loc_keys.get(lid, set())
if chrom is None:
continue
freq: dict[str, float] = defaultdict(float)
for allele, f in con.execute("SELECT allele, AVG(freq) FROM frequencies WHERE gene = ? GROUP BY allele",
(gene,)): # fmt: skip
freq[allele] = float(f or 0)
vmax: dict[tuple[int, str, str], float] = defaultdict(float)
for allele, vs in defs.items():
for v in vs:
vmax[v] = max(vmax[v], freq.get(allele, 0.0))
keys = set().union(*defs.values()) if defs else set()
core = chrom.removeprefix("chr")
vf = pysam.VariantFile(PANEL.format(c=f"chr{core}"))
present = [s for s in inds if s in vf.header.samples]
vf.subset_samples(present)
lo = min(p for p, _, _ in keys) - 10
hi = max(p + len(r) for p, r, _ in keys) + 10
hap_alts: dict[tuple[str, int], set[tuple[int, str, str]]] = defaultdict(set)
seen = set()
for rec in vf.fetch(f"chr{core}", lo, hi):
for ai, alt in enumerate(rec.alts or (), start=1):
k = (rec.pos, rec.ref, alt)
if k not in keys:
continue
seen.add(k)
for s in present:
gt = rec.samples[s]["GT"]
for h, x in enumerate(gt[:2]):
if x == ai:
hap_alts[(s, h)].add(k)
missing_common = [k for k in keys - seen if vmax[k] >= 0.05]
for s in present:
haps = []
for h in (0, 1):
carried = hap_alts[(s, h)]
# the allele that explains most carried variants (its defining ones plus those at its wildcard
# positions), then the most specific, then the most frequent
best = (0, 0, 0.0)
pick = ref_allele
for x in alle:
d = defs.get(x, set())
if not d or not d <= carried:
continue
score = (len(carried & (d | wild.get(x, set()))), len(d), freq.get(x, 0.0))
if score > best or (score == best and natural(x) < natural(pick)):
best, pick = score, x
haps.append(pick)
dip = "/".join(sorted(haps, key=natural))
rows.append((s, gene, dip, "incomplete" if missing_common else ""))
print(f"{gene}: {len(keys)} defining variant(s), {len(seen)} in the panel, "
f"{len(missing_common)} common missing", flush=True) # fmt: skip
with out_path.open("w") as fh:
fh.write("individual\tgene\tdiplotype\tflag\n")
for r in sorted(rows):
fh.write("\t".join(r) + "\n")
print(f"{len(rows)} gene call(s) -> {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Run a heavy command on the 8 GB Pi so that running out of memory kills only this command, never the desktop session.
# MEM=4G MIN_FREE_MB=1200 bash scripts/pi_run.sh <command...>
# 1. When the memory cgroup is available (cmdline.txt: cgroup_enable=memory cgroup_memory=1), the command runs in a
# systemd user scope with MemoryMax=$MEM and no swap: the kernel kills it alone when it exceeds the cap.
# 2. In every case a guard stops the command's process group when the system's MemAvailable drops below
# MIN_FREE_MB (covers other processes filling memory, and systems without the memory cgroup).
# Prints the peak memory of the command when it ends.
set -uo pipefail
MEM=${MEM:-4G}
MIN_FREE_MB=${MIN_FREE_MB:-1200}
uid=$(id -u)
unit="simurg-job-$$-$(date +%s)"
cg="/sys/fs/cgroup/user.slice/user-$uid.slice/user@$uid.service/app.slice/$unit.scope"
if grep -qw memory "/sys/fs/cgroup/user.slice/user-$uid.slice/user@$uid.service/cgroup.controllers" 2>/dev/null; then
setsid systemd-run --user --scope --unit="$unit" -p MemoryMax="$MEM" -p MemorySwapMax=0 --quiet -- "$@" &
mode="cgroup MemoryMax=$MEM"
else
setsid "$@" &
mode="no memory cgroup"
fi
pid=$!
peak=0
anon_peak=0 # process memory without page cache (memory.peak counts cached file pages, e.g. a FASTA being read)
tripped=0
while kill -0 "$pid" 2>/dev/null; do
if [[ -r $cg/memory.peak ]]; then
peak=$(( $(cat "$cg/memory.peak") / 1024 ))
anon=$(awk '$1 == "anon" {print int($2 / 1024)}' "$cg/memory.stat" 2>/dev/null || echo 0)
(( anon > anon_peak )) && anon_peak=$anon
else
rss=$(ps -o rss= -g "$pid" 2>/dev/null | awk '{s+=$1} END {print s+0}'); (( rss > peak )) && peak=$rss
fi
avail=$(awk '/^MemAvailable:/ {print int($2/1024)}' /proc/meminfo)
if (( avail < MIN_FREE_MB )); then
echo "pi_run: MemAvailable ${avail} MiB < ${MIN_FREE_MB} MiB -- stopping the job" >&2
kill -TERM -- "-$pid" 2>/dev/null; sleep 5; kill -KILL -- "-$pid" 2>/dev/null
tripped=1
break
fi
sleep 1
done
wait "$pid"; rc=$?
(( tripped )) && rc=137
(( rc == 137 && ! tripped )) && [[ $mode == cgroup* ]] \
&& echo "pi_run: the job was killed (SIGKILL); most likely it exceeded MemoryMax=$MEM" >&2
anon_note=""
(( anon_peak > 0 )) && anon_note=", process $((anon_peak / 1024)) MiB without page cache"
echo "pi_run: exit $rc, peak memory $((peak / 1024)) MiB$anon_note ($mode, guard ${MIN_FREE_MB} MiB free)" >&2
exit $rc
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Array genotypes vs 1000 Genomes 30x on one chromosome (ArrayTrain validation, plan §12).
python scripts/train/concordance_chr.py --vcfs VCF_DIR --identity identity.tsv --truth chr22.vcf.gz \
--bcftools BCFTOOLS --chrom 22 --out summary.json [--samples-glob '*.snv.vcf.gz']
Per array sample (identity table: sample_id → individual): biallelic SNVs of the chromosome whose (POS, REF, ALT)
is a record of the phased 3202-sample panel; genotype concordance and non-reference discordance (NRD = discordant
/ (discordant + concordant non-ref); plan §16.5) over sites called on the array, plus the array call rate there.
"""
from __future__ import annotations
import argparse
import csv
import json
import subprocess
from pathlib import Path
import numpy as np
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--vcfs", required=True, type=Path)
ap.add_argument("--identity", required=True, type=Path)
ap.add_argument("--truth", required=True, type=Path)
ap.add_argument("--bcftools", default="bcftools")
ap.add_argument("--chrom", default="22")
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
with a.identity.open() as fh:
ident = {r["sample_id"]: r["individual"] for r in csv.DictReader((ln for ln in fh if not ln.startswith("#")),
delimiter="\t")} # fmt: skip
vcfs = {p.name.split(".")[0]: p for p in sorted(a.vcfs.rglob("*.snv.vcf.gz"))}
vcfs = {k: v for k, v in vcfs.items() if k in ident}
inds = sorted({ident[k] for k in vcfs})
# array sites (from the first VCF) → truth genotypes of the individuals at those sites
first = next(iter(vcfs.values()))
q = subprocess.run([a.bcftools, "query", "-r", a.chrom, "-i", 'TYPE="snp" && N_ALT=1', "-f", "%POS\t%REF\t%ALT\n",
str(first)], capture_output=True, text=True, check=True).stdout # fmt: skip
sites = {(int(p), r, al) for p, r, al in (ln.split("\t") for ln in q.splitlines())}
reg = a.out.with_suffix(".sites.tsv")
chrom_t = f"chr{a.chrom}"
reg.write_text("".join(f"{chrom_t}\t{p}\n" for p in sorted({s[0] for s in sites})))
tq = subprocess.run([a.bcftools, "query", "-R", str(reg), "-s", ",".join(inds), "--force-samples", "-i",
'TYPE="snp"', "-f", "%POS\t%REF\t%ALT[\t%GT]\n", str(a.truth)], capture_output=True, text=True,
check=True).stdout # fmt: skip
head = subprocess.run([a.bcftools, "query", "-l", "-s", ",".join(inds), "--force-samples", str(a.truth)],
capture_output=True, text=True, check=True).stdout.split() # fmt: skip
truth: dict[tuple[int, str, str], dict[str, int]] = {}
for ln in tq.splitlines():
f = ln.split("\t")
k = (int(f[0]), f[1], f[2])
if k in sites:
truth[k] = {
s: sum(x == "1" for x in g.replace("|", "/").split("/")) for s, g in zip(head, f[3:], strict=True)
}
per = []
for sid, path in sorted(vcfs.items()):
ind = ident[sid]
aq = subprocess.run([a.bcftools, "query", "-r", a.chrom, "-f", "%POS\t%REF\t%ALT[\t%GT]\n", str(path)],
capture_output=True, text=True, check=True).stdout # fmt: skip
n = called = conc = nrd_bad = nrd_ok = 0
for ln in aq.splitlines():
p, r, al, gt = ln.split("\t")
k = (int(p), r, al)
t = truth.get(k, {}).get(ind)
if t is None:
continue
n += 1
if "." in gt:
continue
called += 1
d = sum(x == "1" for x in gt.replace("|", "/").split("/"))
conc += d == t
if d > 0 or t > 0:
nrd_ok += d == t
nrd_bad += d != t
per.append({"sample": sid, "individual": ind, "sites": n, "call_rate": called / n if n else None,
"concordance": conc / called if called else None,
"nrd": nrd_bad / (nrd_bad + nrd_ok) if nrd_bad + nrd_ok else None}) # fmt: skip
summ = {k: float(np.mean([p[k] for p in per if p[k] is not None])) for k in ("call_rate", "concordance", "nrd")}
a.out.write_text(json.dumps({"chrom": a.chrom, "samples": len(per), "truth_sites": len(truth), **summ,
"per_sample": per}, indent=1)) # fmt: skip
print(json.dumps({"samples": len(per), "truth_sites": len(truth), **summ}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Sensitivity of `simurg cn call` on implanted events (spec CYTO-022, plan §16.4).
Real GTCs of normal samples are copied and events are written into their LRR/BAF:
constitutional DEL (CN1: LRR − 0.45, heterozygous BAF → 0/1), DUP (CN3: LRR + 0.3, het → 1/3 or 2/3), LOH (het → 0/1);
mosaic loss / gain / CN-LOH with cell fraction f (LRR shifted by f × the constitutional shift, heterozygous BAF
moved to 0.5 ± d(f), sign random per probe as in unphased data), keeping each probe's original noise.
Every synthetic sample gets several events on different chromosomes. `simurg cn call` then runs on them and an
implanted event counts as detected when a called event of the same class (loss / gain / LOH) overlaps ≥ 50 % of it.
python scripts/v2/cn_synthetic_eval.py --bpm M.bpm --model MODEL --gtcs BASE_DIR --out OUT [--threads N]
"""
from __future__ import annotations
import argparse
import copy
import gzip
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
from simurg.formats.bpm import read_bpm
from simurg.formats.discovery import discover_gtcs
from simurg.formats.gtc import read_gtc, write_gtc
KINDS = [("DEL", 1.0), ("DUP", 1.0), ("LOH", 1.0)] + [
(k, f) for k in ("MDEL", "MDUP", "MLOH") for f in (0.05, 0.10, 0.15, 0.20, 0.30)
]
SIZES = (1_000_000, 5_000_000, 20_000_000)
CLASS = {"DEL": "loss", "MDEL": "loss", "DUP": "gain", "MDUP": "gain", "LOH": "loh", "MLOH": "loh", "GAINLOH": "gain"}
CHROMS = [str(c) for c in range(1, 21)] # leave 21, 22, X, Y untouched
def implant(lrr: np.ndarray, baf: np.ndarray, idx: np.ndarray, kind: str, f: float, rng: np.random.Generator) -> None:
het = (baf[idx] > 0.15) & (baf[idx] < 0.85)
h = idx[het]
noise = baf[h] - 0.5
sign = rng.choice([-1.0, 1.0], len(h))
if kind in ("DEL", "LOH"):
if kind == "DEL":
lrr[idx] -= 0.45
baf[h] = np.clip(np.where(sign > 0, 1.0, 0.0) + noise * 0.2, 0, 1)
elif kind == "DUP":
lrr[idx] += 0.30
baf[h] = np.clip(np.where(sign > 0, 2 / 3, 1 / 3) + noise, 0, 1)
else:
d = {"MDEL": f / (2 * (2 - f)), "MDUP": f / (2 * (2 + f)), "MLOH": f / 2}[kind]
lrr[idx] += {"MDEL": -0.45 * f, "MDUP": 0.30 * f, "MLOH": 0.0}[kind]
baf[h] = np.clip(0.5 + sign * d + noise, 0, 1)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--bpm", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--gtcs", required=True, type=Path, help="normal base samples (not in the model's reference)")
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--threads", type=int, default=0)
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--reps", type=int, default=4, help="replicates of every (kind, fraction, size) cell")
a = ap.parse_args()
rng = np.random.default_rng(a.seed)
man = read_bpm(a.bpm)
chroms = np.array(man.chroms)
pos = man.map_infos.astype(np.int64)
base = sorted(discover_gtcs(a.gtcs).values())
syn = a.out / "gtcs"
syn.mkdir(parents=True, exist_ok=True)
events = [(k, f, s) for k, f in KINDS for s in SIZES for _ in range(a.reps)]
rng.shuffle(events)
truth = []
per_sample = len(CHROMS) // 2 # one event per chromosome pair slot, spaced out
for n, start in enumerate(range(0, len(events), per_sample)):
chunk = events[start : start + per_sample]
src = base[n % len(base)]
g = copy.deepcopy(read_gtc(src))
lrr = g.logr_ratios.astype(np.float64)
baf = g.b_allele_freqs.astype(np.float64)
chosen = rng.permutation(CHROMS)[: len(chunk)]
sid = f"syn{n:03d}"
for (kind, f, size), c in zip(chunk, chosen, strict=True):
sel = np.flatnonzero((chroms == c) & (pos > 0))
lo, hi = int(pos[sel].min()), int(pos[sel].max())
s0 = int(rng.integers(lo + 2_000_000, max(lo + 2_000_001, hi - size - 2_000_000)))
idx = sel[(pos[sel] >= s0) & (pos[sel] < s0 + size)]
implant(lrr, baf, idx, kind, f, rng)
truth.append({"sample": sid, "chrom": c, "start": s0, "end": s0 + size - 1, "kind": kind, "fraction": f,
"size": size, "probes": len(idx), "base": src.stem}) # fmt: skip
g.logr_ratios = lrr.astype(np.float32)
g.b_allele_freqs = baf.astype(np.float32)
(syn / sid).mkdir(exist_ok=True)
write_gtc(g, syn / sid / f"{sid}.gtc")
(a.out / "truth.json").write_text(json.dumps(truth, indent=1))
simurg = str(Path(sys.executable).with_name("simurg"))
cmd = [simurg, "cn", "call", "--bpm", a.bpm, "--model", a.model, "--gtcs", str(syn), "--out", str(a.out / "call"),
"--threads", str(a.threads), "--unknown-sex-policy", "female", "--force", "--quiet"] # fmt: skip
subprocess.run(cmd, check=True)
calls: dict[str, list[tuple[str, int, int, str, str]]] = {}
for vcf in (a.out / "call").glob("*.cyto.cnv.vcf.gz"):
sid = vcf.name.split(".")[0]
with gzip.open(vcf, "rt") as fh:
lines = [ln for ln in fh if not ln.startswith("#")]
for line in lines:
c, p, vid, _, _alt, _q, filt, info = line.split("\t")[:8]
end = int(next(x for x in info.split(";") if x.startswith("END="))[4:])
call = vid.split(":")[0]
cls = {"DEL": "loss", "MosaicDEL": "loss", "DUP": "gain", "MosaicDUP": "gain", "AOH": "loh",
"CNLOH": "loh", "GAINLOH": "gain"}[call] # fmt: skip
calls.setdefault(sid, []).append((c, int(p) + 1, end, cls, filt))
for t in truth:
cover = 0
seen = []
for c, s, e, cls, filt in calls.get(t["sample"], []):
ov = max(0, min(e, t["end"]) - max(s, t["start"]) + 1) if c == t["chrom"] else 0
if ov:
seen.append(f"{cls}:{filt}:{ov / (t['end'] - t['start'] + 1):.2f}")
if cls == CLASS[t["kind"]]:
cover += ov # fragments of one event add up
frac = cover / (t["end"] - t["start"] + 1)
t["detected"] = frac >= 0.5
t["overlap"] = round(frac, 3)
t["overlapping_calls"] = seen
cells: dict[str, dict[str, list[bool]]] = {}
for t in truth:
key = t["kind"] if t["fraction"] == 1.0 else f"{t['kind']} f={t['fraction']:.2f}"
cells.setdefault(key, {}).setdefault(f"{t['size'] // 1_000_000} Mb", []).append(t["detected"])
table = {k: {s: f"{sum(v)}/{len(v)}" for s, v in d.items()} for k, d in cells.items()}
n_calls = sum(len(v) for v in calls.values())
implanted_hits = sum(t["detected"] for t in truth)
summary = {"events": len(truth), "detected": implanted_hits, "table": table, "calls_total": n_calls}
(a.out / "summary.json").write_text(json.dumps(summary, indent=1))
(a.out / "truth_detected.json").write_text(json.dumps(truth, indent=1))
for k, v in table.items():
print(f"{k:14s} " + " ".join(f"{s}: {v.get(s, '-')}" for s in ("1 Mb", "5 Mb", "20 Mb")))
print(f"detected {implanted_hits}/{len(truth)}; {n_calls} calls in {len(calls)} samples")
for t in truth:
if not t["detected"] and t["kind"] in ("DUP", "DEL", "LOH") and t["size"] >= 5_000_000:
print(" missed", t["kind"], t["size"], t["probes"], "probes:", t["overlapping_calls"])
return 0
if __name__ == "__main__":
raise SystemExit(main())
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# CN tuning round on the Pi (GSE165845): reference = replicate-3 arrays of all 120 individuals, test = replicates 1
# and 2 of the first N individuals; truth = NYGC SV rows (data/v2cn/cloud1/truth.tsv).
# bash scripts/v2/cn_tune.sh TAG [extra `simurg cn call` options...] (env: N=20, MODEL=...)
set -euo pipefail
cd "$(dirname "$0")/../.."
TAG=${1:?tag}; shift
N=${N:-20}
G=data/v2cn/gse_gt; M=data/v2cn/gse_idats/samples_map.tsv; W=data/v2cn/tune
BPM=data/p8/gsa1/GSA-24v1-0_C2.bpm
mkdir -p $W/ref $W/test
if [[ ! -f $W/.dirs ]]; then
inds=$(tail -n +2 $M | cut -f3 | awk '!s[$0]++' | head -n "$N")
tail -n +2 $M | while IFS=$'\t' read -r sid gsm ind rep; do
[[ $rep == 3 ]] && ln -sfn "$(realpath $G/samples/$sid)" $W/ref/$sid
if [[ $rep != 3 ]] && grep -qx "$ind" <<< "$inds"; then ln -sfn "$(realpath $G/samples/$sid)" $W/test/$sid; fi
done
touch $W/.dirs
fi
MODEL=${MODEL:-$W/ref.simurg-cyto}
if [[ ! -f $MODEL ]]; then
MEM=${MEM:-5G} MIN_FREE_MB=1500 bash scripts/pi_run.sh .venv/bin/simurg cn build-model --bpm $BPM --fasta data/p6/GRCh38.primary.fa \
--egt data/p8/gsa1/GSA-24v1-0_C1_ClusterFile.egt --reference-gtcs $W/ref --max-reference 120 --out $MODEL --quiet
fi
MEM=${MEM:-5G} MIN_FREE_MB=1500 bash scripts/pi_run.sh .venv/bin/simurg cn call --bpm $BPM --model $MODEL --gtcs $W/test \
--summary $G/sample_summary.tsv --out $W/$TAG --threads 3 --force --quiet "$@"
.venv/bin/python scripts/v2/cn_validate_truth.py --calls $W/$TAG --map $M --truth data/v2cn/cloud1/truth.tsv --bpm $BPM \
--min-qual 20 --out $W/$TAG/val > /dev/null
.venv/bin/python scripts/v2/cn_tune_report.py $W/$TAG $M
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""One-line-per-call-type summary of a tuning round: calls, fraction in NYGC truth, fraction seen in the other
replicate; plus truth sensitivity by size bin (QUAL >= 20, PASS or SampleQuality)."""
from __future__ import annotations
import csv
import json
import sys
from collections import defaultdict
from pathlib import Path
def ro(a: tuple[int, int], b: tuple[int, int]) -> float:
ov = min(a[1], b[1]) - max(a[0], b[0]) + 1
return 0.0 if ov <= 0 else min(ov / (a[1] - a[0] + 1), ov / (b[1] - b[0] + 1))
def main() -> int:
run, mapf = Path(sys.argv[1]), Path(sys.argv[2])
with mapf.open() as fh:
smap = {r["sample_id"]: r["individual"] for r in csv.DictReader(fh, delimiter="\t")}
truth: dict[str, list[tuple[str, int, int, str]]] = defaultdict(list)
with open("data/v2cn/cloud1/truth.tsv") as fh:
for line in fh:
c, s, e, t, i = line.rstrip("\n").split("\t")
truth[i].append((c.removeprefix("chr"), int(s), int(e), t))
side = json.loads((run / "cn_call_manifest.json").read_text())
calls = {s["sample_id"]: [e for e in s["events"] if e["quality"] >= 20 and set(e["filters"]) <= {"PASS",
"SampleQuality"}] for s in side["samples"] if s.get("status") == "OK"} # fmt: skip
cls = {"DEL": "DEL", "MDEL": "DEL", "DUP": "DUP", "MDUP": "DUP"}
by_ind: dict[str, list[str]] = defaultdict(list)
for sid in calls:
by_ind[smap[sid]].append(sid)
st: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0, 0])
for sid, evs in calls.items():
ind = smap[sid]
others = [calls[x] for x in by_ind[ind] if x != sid]
for e in evs:
k = e["call"]
v = st[k]
v[0] += 1
if k in cls:
v[1] += any(t == cls[k] and c == e["chrom"] and ro((s, en), (e["start"], e["end"])) >= 0.5
for c, s, en, t in truth[ind]) # fmt: skip
for oc in others:
v[2] += any(o["chrom"] == e["chrom"] and o["call"] == k and ro((o["start"], o["end"]),
(e["start"], e["end"])) >= 0.5 for o in oc) # fmt: skip
v[3] += 1
print(f"{run.name}: {len(calls)} samples")
for k, v in sorted(st.items()):
tr = f"{v[1] / v[0]:.2f}" if k in cls and v[0] else " - "
rp = f"{v[2] / v[3]:.2f}" if v[3] else " - "
print(f" {k:8s} calls {v[0]:5d} in-truth {tr} replicated {rp}")
val = json.loads((run / "val" / "summary.json").read_text())
for t, bins in val["sensitivity"].items():
print(f" sensitivity {t}: " + ", ".join(f"{b} {x['found']}/{x['truth']}" for b, x in sorted(bins.items())))
for t, bins in val.get("sensitivity_by_carrier_frequency", {}).items():
print(f" by frequency {t}: " + ", ".join(f"{b} {x['found']}/{x['truth']}" for b, x in sorted(bins.items())))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Validate `simurg cn call` on GSE165845 (120 1000 Genomes individuals × 3 GSA arrays) against the NYGC 30x
structural-variant callset (1KGP_3202 GATK-SV/svtools freeze V3) and between replicates.
python scripts/v2/cn_validate_truth.py --calls CN_OUT --map samples_map.tsv --truth truth.tsv --bpm M.bpm --out DIR
``truth.tsv``: ``chrom start end svtype sample`` for non-reference DEL/DUP genotypes of the 120 individuals
(``bcftools query``; chr prefix removed). Matching: reciprocal overlap ≥ 50 % and the same class (DEL ↔ losses
incl. mosaic, DUP ↔ gains). Only truth events with ≥ 10 array probes are counted as detectable. Sensitivity is also
split by the event's carrier frequency among the individuals in ``truth.tsv`` (identical chrom/start/end/type):
common events are normalised away by a median-LRR batch reference (K-027), so ``rare`` (< 5 %) is the primary metric.
"""
from __future__ import annotations
import argparse
import csv
import json
import statistics
from collections import defaultdict
from pathlib import Path
import numpy as np
from simurg.formats.bpm import read_bpm
FREQ = [(0.0, 0.05, "rare<5%"), (0.05, 0.2, "5-20%"), (0.2, 1.01, "common>=20%")]
BINS = [(25_000, 50_000), (50_000, 100_000), (100_000, 500_000), (500_000, 1_000_000), (1_000_000, 10**9)]
CLASS = {"DEL": "DEL", "MDEL": "DEL", "DUP": "DUP", "MDUP": "DUP"}
def ro(a: tuple[int, int], b: tuple[int, int]) -> float:
ov = min(a[1], b[1]) - max(a[0], b[0]) + 1
if ov <= 0:
return 0.0
return min(ov / (a[1] - a[0] + 1), ov / (b[1] - b[0] + 1))
def bin_of(size: int) -> str:
for lo, hi in BINS:
if lo <= size < hi:
return f"{lo // 1000}-{hi // 1000 if hi < 10**9 else 'inf'}kb"
return "<25kb"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--calls", required=True, type=Path)
ap.add_argument("--map", required=True, type=Path)
ap.add_argument("--truth", required=True, type=Path)
ap.add_argument("--bpm", required=True)
ap.add_argument("--min-qual", type=float, default=20)
ap.add_argument("--ignore-filter", action="append", default=[], help="FILTER values not to exclude (repeatable)")
ap.add_argument("--out", required=True, type=Path)
a = ap.parse_args()
a.out.mkdir(parents=True, exist_ok=True)
man = read_bpm(a.bpm)
chroms = np.array(["X" if c == "XY" else c for c in man.chroms])
pos = man.map_infos.astype(np.int64)
probe_pos = {c: np.sort(pos[(chroms == c) & (pos > 0)]) for c in set(chroms.tolist())}
def n_probes(c: str, s: int, e: int) -> int:
p = probe_pos.get(c)
return 0 if p is None else int(np.searchsorted(p, e, "right") - np.searchsorted(p, s, "left"))
with a.map.open() as fh:
smap = {r["sample_id"]: r for r in csv.DictReader(fh, delimiter="\t")}
side = json.loads((a.calls / "cn_call_manifest.json").read_text())
calls: dict[str, list[dict]] = {}
for s in side["samples"]:
if s.get("status") != "OK":
continue
ign = set(a.ignore_filter) | {"PASS"}
calls[s["sample_id"]] = [e for e in s["events"] if e["quality"] >= a.min_qual and set(e["filters"]) <= ign]
truth: dict[str, list[tuple[str, int, int, str]]] = defaultdict(list)
carriers: dict[tuple[str, int, int, str], set[str]] = defaultdict(set)
with a.truth.open() as fh:
for line in fh:
c, s_, e_, t, ind = line.rstrip("\n").split("\t")
c = c[3:] if c.startswith("chr") else c
carriers[(c, int(s_), int(e_), t)].add(ind)
if t in ("DEL", "DUP") and int(e_) - int(s_) + 1 >= 25_000:
truth[ind].append((c, int(s_), int(e_), t))
n_ind = len({i for v in carriers.values() for i in v}) or 1
def freq_class(key: tuple[str, int, int, str]) -> str:
f = len(carriers[key]) / n_ind
return next(name for lo, hi, name in FREQ if lo <= f < hi)
# sensitivity per class and size bin
sens: dict[str, dict[str, list[int]]] = defaultdict(lambda: defaultdict(lambda: [0, 0]))
sens_freq: dict[str, dict[str, list[int]]] = defaultdict(lambda: defaultdict(lambda: [0, 0]))
prec = defaultdict(lambda: [0, 0])
per_sample = []
for sid, evs in calls.items():
ind = smap.get(sid, {}).get("individual")
if ind is None:
continue
tr = [t for t in truth.get(ind, []) if n_probes(t[0], t[1], t[2]) >= 10]
hit_t = 0
for c, s_, e_, t in tr:
ok = any(CLASS.get(e["call"]) == t and e["chrom"] == c and ro((s_, e_), (e["start"], e["end"])) >= 0.5
for e in evs) # fmt: skip
cell = sens[t][bin_of(e_ - s_ + 1)]
cell[0] += ok
cell[1] += 1
fc = sens_freq[t][freq_class((c, s_, e_, t))]
fc[0] += ok
fc[1] += 1
hit_t += ok
hit_c = 0
cnv_calls = [e for e in evs if e["call"] in CLASS]
for e in cnv_calls:
ok = any(t == CLASS[e["call"]] and c == e["chrom"] and ro((s_, e_), (e["start"], e["end"])) >= 0.5
for c, s_, e_, t in truth.get(ind, [])) # fmt: skip
p = prec[CLASS[e["call"]]]
p[0] += ok
p[1] += 1
hit_c += ok
per_sample.append({"sample": sid, "individual": ind, "truth_detectable": len(tr), "truth_found": hit_t,
"cnv_calls": len(cnv_calls), "calls_in_truth": hit_c,
"loh_calls": sum(e["call_type"] == "LOH" for e in evs)}) # fmt: skip
# replicate reproducibility of CNV and LOH calls
by_ind: dict[str, list[str]] = defaultdict(list)
for sid in calls:
if sid in smap:
by_ind[smap[sid]["individual"]].append(sid)
rep = {"CNV": [0, 0], "LOH": [0, 0]}
def kind_of(e: dict) -> str | None:
if e["call_type"] == "LOH":
return "LOH"
return "CNV" if e["call"] in CLASS else None
def matched(e: dict, others: list[dict], kind: str) -> bool:
return any(
o["chrom"] == e["chrom"]
and kind_of(o) == kind
and ro((o["start"], o["end"]), (e["start"], e["end"])) >= 0.5
for o in others
)
for sids in by_ind.values():
for i, s1 in enumerate(sids):
for e in calls[s1]:
kind = kind_of(e)
if kind is None:
continue
for j, s2 in enumerate(sids):
if j != i:
rep[kind][0] += matched(e, calls[s2], kind)
rep[kind][1] += 1
def rate(v: list[int]) -> float | None:
return round(v[0] / v[1], 4) if v[1] else None
summary = {
"samples": len(per_sample),
"min_qual": a.min_qual,
"ignored_filters": sorted(a.ignore_filter),
"sensitivity": {
t: {b: {"found": v[0], "truth": v[1], "rate": rate(v)} for b, v in sorted(d.items())}
for t, d in sens.items()
},
"sensitivity_by_carrier_frequency": {
t: {b: {"found": v[0], "truth": v[1], "rate": rate(v)} for b, v in sorted(d.items())}
for t, d in sens_freq.items()
},
"precision": {t: {"in_truth": v[0], "calls": v[1], "rate": rate(v)} for t, v in prec.items()},
"replicate_reproducibility": {
k: {"matched": v[0], "comparisons": v[1], "rate": rate(v)} for k, v in rep.items()
},
"calls_per_sample_median": statistics.median([p["cnv_calls"] for p in per_sample]) if per_sample else None,
"loh_calls_per_sample_median": statistics.median([p["loh_calls"] for p in per_sample]) if per_sample else None,
}
(a.out / "summary.json").write_text(json.dumps(summary, indent=1))
with (a.out / "per_sample.tsv").open("w") as fh:
w = csv.DictWriter(fh, fieldnames=list(per_sample[0]) if per_sample else ["sample"], delimiter="\t")
w.writeheader()
w.writerows(per_sample)
print(json.dumps(summary, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Compare `simurg methyl qc` numerics with the sesame oracle (scripts/v2/sesame_oracle.R) on the same probes.
python scripts/v2/methyl_oracle_compare.py --manifest M.csv --sesame DIR --out summary.json PREFIX...
"""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
import numpy as np
from simurg.formats.idat import read_idat
from simurg.formats.methyl_manifest import read_methyl_manifest
from simurg.signal import methyl as sig
def _q(a: np.ndarray) -> dict[str, float]:
a = a[np.isfinite(a)]
if not len(a):
return {}
return {k: float(np.quantile(a, q)) for k, q in (("median", 0.5), ("p99", 0.99), ("max", 1.0))}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", required=True)
ap.add_argument("--sesame", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("prefixes", nargs="+")
a = ap.parse_args()
man = read_methyl_manifest(a.manifest)
pos = {p: i for i, p in enumerate(man.probe_ids)}
res = {}
for prefix in a.prefixes:
# min_beads=0: the oracle runs without bead-count masking (see sesame_oracle.R)
s = sig.assemble(read_idat(prefix + "_Grn.idat"), read_idat(prefix + "_Red.idat"), man, min_beads=0)
rows = list(csv.DictReader((a.sesame / (Path(prefix).name + ".tsv")).open(), delimiter="\t"))
idx = np.array([pos[r["probe_id"]] for r in rows])
def col(name: str, rows: list[dict[str, str]] = rows) -> np.ndarray:
return np.array([float(r[name]) if r[name] not in ("NA", "") else np.nan for r in rows])
mask_s = np.array([r["mask"] == "TRUE" for r in rows])
variants = {}
for neg in (True, False):
p = sig.detection_p(s, combine_neg=neg)
n, fg, fr = sig.noob(s, combine_neg=neg)
variants[neg] = (p, n, fg, fr, sig.beta_values(n))
keep = ~mask_s & ~s.masked[idx]
sp, sm, su, sb = col("pval"), col("m_noob"), col("u_noob"), col("beta_noob")
ana = man.analytical()[idx] & keep
out: dict[str, object] = {
"probes": len(rows),
"masked_sesame": int(mask_s.sum()),
"masked_simurg": int(s.masked.sum()),
}
for neg, (p, n, fg, fr, b) in variants.items():
op, om, ou, ob = p[idx], n.m[idx], n.u[idx], b[idx]
out[f"combine_neg={neg}"] = {
"noob_fit": {"green": vars(fg), "red": vars(fr)},
"pval_abs_diff": _q(np.abs(op - sp)[keep]),
"pval_identical_fraction": float((op[keep] == sp[keep]).mean()),
"detection_call_agreement_0.05": float(((op < 0.05) == (sp < 0.05))[keep].mean()),
"prop_passing": {"simurg": float((op[ana] < 0.05).mean()), "sesame": float((sp[ana] < 0.05).mean())},
"noob_m_rel_diff": _q(np.abs(om - sm)[keep] / np.maximum(np.abs(sm[keep]), 1)),
"noob_u_rel_diff": _q(np.abs(ou - su)[keep] / np.maximum(np.abs(su[keep]), 1)),
"beta_abs_diff": _q(np.abs(ob - sb)[keep]),
"beta_corr": float(np.corrcoef(ob[keep & np.isfinite(sb)], sb[keep & np.isfinite(sb)])[0, 1]),
}
res[Path(prefix).name] = out
a.out.write_text(json.dumps(res, indent=1))
print(json.dumps(res, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+48
View File
@@ -0,0 +1,48 @@
# Oracle for `simurg methyl qc` numerics: sesame (MIT) pOOBAH, noob and beta values on the same probe set.
# Usage: Rscript sesame_oracle.R probes.tsv controls.tsv OUTDIR IDAT_PREFIX...
# probes.tsv / controls.tsv are written by `simurg methyl convert-manifest` (MET-003: sesame manifest layout).
suppressPackageStartupMessages(library(sesame))
args <- commandArgs(trailingOnly = TRUE)
p <- read.delim(args[1], stringsAsFactors = FALSE, na.strings = "NA", colClasses = c("character", "numeric",
"numeric", "character"))
mft <- data.frame(Probe_ID = p$probe_id, M = p$m, U = p$u, col = ifelse(is.na(p$col), "2", p$col),
stringsAsFactors = FALSE)
mft$col <- factor(mft$col, levels = c("G", "R", "2"))
ctl <- read.delim(args[2], stringsAsFactors = FALSE)
controls <- data.frame(Address = as.character(ctl$address), Type = ctl$type, Color_Channel = ctl$color_channel,
Name = ctl$name, stringsAsFactors = FALSE)
out <- args[3]
dir.create(out, showWarnings = FALSE, recursive = TRUE)
cat("sesame", as.character(packageVersion("sesame")), "\n")
# platform-level annotation (masks, signatures) that sesame looks up even when the manifest is given
invisible(tryCatch(sesameDataCache("EPIC"), error = function(e) message("cache EPIC: ", conditionMessage(e))))
invisible(tryCatch(sesameDataCache(c("idatSignature", "KYCG.EPIC.Mask.20220123")),
error = function(e) message("cache: ", conditionMessage(e))))
inband <- function(s) {
m <- ifelse(s$col == "R", s$MR, ifelse(s$col == "G", s$MG, s$UG))
u <- ifelse(s$col == "R", s$UR, ifelse(s$col == "G", s$UG, s$UR))
list(m = m, u = u)
}
for (prefix in args[-(1:3)]) {
# no bead-count masking here (sesame's min_beads also masks every Infinium II probe, whose M address is NA);
# the comparison script runs simurg with the same setting
read <- function() readIDATpair(prefix, platform = "EPIC", manifest = mft, controls = controls)
sdf <- tryCatch(read(), error = function(e) {
message("first read failed (", conditionMessage(e), "); caching idatSignature and retrying")
sesameDataCache("idatSignature")
read()
})
neg <- tryCatch(negControls(sdf), error = function(e) NULL)
cat("negative controls found by sesame:", if (is.null(neg)) "error" else nrow(neg), "\n")
print(head(sdf[grepl("^ctl", sdf$Probe_ID), ], 3))
pv <- pOOBAH(sdf, return.pval = TRUE)
sn <- noob(sdf)
ib <- inband(sn)
b <- getBetas(sn, mask = FALSE)
res <- data.frame(probe_id = sdf$Probe_ID, mask = sdf$mask, pval = pv[sdf$Probe_ID],
m_noob = ib$m, u_noob = ib$u, beta_noob = b[sdf$Probe_ID])
f <- file.path(out, paste0(basename(prefix), ".tsv"))
write.table(res, f, sep = "\t", quote = FALSE, row.names = FALSE)
cat(basename(prefix), nrow(res), "probes;", sum(res$mask), "masked\n")
}
sessionInfo()
+1 -1
View File
@@ -4,5 +4,5 @@ Specification: SIMURGARRAY_MASTER_PLAN.md (algorithms, roadmap) and
SIMURGARRAY_FUNCTIONAL_SPEC.md (externally visible behaviour, item IDs such as CLI-021). SIMURGARRAY_FUNCTIONAL_SPEC.md (externally visible behaviour, item IDs such as CLI-021).
""" """
__version__ = "1.1.0" __version__ = "2.0.0"
PRODUCT_NAME = "SimurgArray" PRODUCT_NAME = "SimurgArray"
+418
View File
@@ -666,6 +666,424 @@ def qc_controls_cmd(
run_guarded(rep, lambda: run_qc_controls(opts, rep)) run_guarded(rep, lambda: run_qc_controls(opts, rep))
# --------------------------------------------------------------------------------------------------------
cn_app = typer.Typer(help="Genome-wide copy number, LOH and mosaicism (CYTO-001).")
app.add_typer(cn_app, name="cn")
@cn_app.command("build-model")
def cn_build_model_cmd(
bpm: Annotated[Path, typer.Option("--bpm", help="BPM manifest (required).")],
fasta: Annotated[Path, typer.Option("--fasta", help="Reference FASTA of the manifest's build (required).")],
out: Annotated[Path, typer.Option("--out", help="Output model file (.simurg-cyto).")],
egt: Annotated[Path | None, typer.Option("--egt", help="Cluster file: population B-allele frequencies.")] = None,
reference_gtcs: Annotated[
Path | None, typer.Option("--reference-gtcs", help="GTCs of normal samples (>= 24) for the batch reference.")
] = None,
max_reference: Annotated[int, typer.Option("--max-reference", min=1, help="Random subset size.")] = 100,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Cyto model: GC covariates, PFB and an optional batch reference per probe (CYTO-038, IN-051)."""
from simurg.pipeline.cn import run_build_model
rep = _reporter("cn build-model", out.parent, log_json, debug, quiet)
run_guarded(rep, lambda: run_build_model(bpm, fasta, out, rep, egt, "auto", reference_gtcs, max_reference))
def _kb(v: int) -> int:
if not 0 <= v <= 250_000:
raise typer.BadParameter("sizes are 0-250000 kb")
return v * 1000
@cn_app.command("call")
def cn_call_cmd(
bpm: Annotated[Path, typer.Option("--bpm", help="BPM manifest (required).")],
model: Annotated[Path, typer.Option("--model", help="Cyto model from cn build-model (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
gtcs: Annotated[Path | None, typer.Option("--gtcs", help="Folder searched recursively for GTC files.")] = None,
summary: Annotated[
Path | None, typer.Option("--summary", help="sample_summary.tsv (default: next to GTCs).")
] = None,
fasta: Annotated[Path | None, typer.Option("--fasta", help="FASTA for REF padding bases.")] = None,
min_del_probes: Annotated[int, typer.Option("--min-del-probes", min=0, max=250000)] = 10,
min_del_size: Annotated[int, typer.Option("--min-del-size", help="kb")] = 25,
min_dup_probes: Annotated[int, typer.Option("--min-dup-probes", min=0, max=250000)] = 10,
min_dup_size: Annotated[int, typer.Option("--min-dup-size", help="kb")] = 50,
min_loh_probes: Annotated[int, typer.Option("--min-loh-probes", min=0, max=250000)] = 500,
min_loh_size: Annotated[int, typer.Option("--min-loh-size", help="kb")] = 3000,
min_gainloh_probes: Annotated[int, typer.Option("--min-gainloh-probes", min=0, max=250000)] = 300,
min_gainloh_size: Annotated[int, typer.Option("--min-gainloh-size", help="kb")] = 500,
min_mdel_probes: Annotated[int, typer.Option("--min-mdel-probes", min=0, max=250000)] = 10,
min_mdel_size: Annotated[int, typer.Option("--min-mdel-size", help="kb")] = 25,
min_mdup_probes: Annotated[int, typer.Option("--min-mdup-probes", min=0, max=250000)] = 10,
min_mdup_size: Annotated[int, typer.Option("--min-mdup-size", help="kb")] = 50,
min_mloh_probes: Annotated[int, typer.Option("--min-mloh-probes", min=0, max=250000)] = 500,
min_mloh_size: Annotated[int, typer.Option("--min-mloh-size", help="kb")] = 3000,
smoothing: Annotated[int, typer.Option("--smoothing", min=0, max=1000)] = 5,
max_mosaic_fraction: Annotated[float, typer.Option("--max-mosaic-fraction")] = 1.0,
min_mosaic_fraction: Annotated[float, typer.Option("--min-mosaic-fraction")] = 0.10,
filter_qual: Annotated[int, typer.Option("--filter-qual", min=0, max=60)] = 10,
cn_cap: Annotated[int, typer.Option("--cn-cap", min=3, max=8)] = 4,
gc_correct: Annotated[bool, typer.Option("--gc-correct/--no-gc-correct")] = True,
unknown_sex_policy: Annotated[str, typer.Option("--unknown-sex-policy")] = "skip-sex-chromosomes",
gt_mode: Annotated[str, typer.Option("--gt-mode", help="vcf|compat")] = "vcf",
svlen_mode: Annotated[str, typer.Option("--svlen-mode", help="signed|positive")] = "signed",
mask_bed: Annotated[Path | None, typer.Option("--mask-bed", help="Mask BED (default: built-in).")] = None,
no_bgzip: Annotated[bool, typer.Option("--no-bgzip", help="Plain VCF (cn annotate then refuses it).")] = False,
mosaic_prior: Annotated[
float, typer.Option("--mosaic-prior", help="Advanced: mosaic states' switch mass.")
] = 0.004,
switch_distance: Annotated[
float, typer.Option("--switch-distance", help="Advanced: HMM switch distance D (bp).")
] = 5e8,
probe_lrr_sd: Annotated[
bool, typer.Option("--probe-lrr-sd/--no-probe-lrr-sd", help="Advanced: per-probe LRR noise from the model.")
] = True,
threads: ThreadsOpt = 0,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""GTCs -> per-sample CNV/LOH/mosaic VCF, segments and summary (CLI-028)."""
from simurg.cn.events import Thresholds
from simurg.pipeline.cn import CnCallOptions, run_cn_call
if unknown_sex_policy not in ("skip-sex-chromosomes", "female", "male", "error"):
raise typer.BadParameter("--unknown-sex-policy: skip-sex-chromosomes|female|male|error")
if gt_mode not in ("vcf", "compat") or svlen_mode not in ("signed", "positive"):
raise typer.BadParameter("--gt-mode vcf|compat, --svlen-mode signed|positive")
if not 0 < max_mosaic_fraction <= 1 or not 0 <= min_mosaic_fraction < 1:
raise typer.BadParameter("mosaic fractions: 0 < max <= 1, 0 <= min < 1")
th = Thresholds(
probes={"del": min_del_probes, "dup": min_dup_probes, "loh": min_loh_probes, "gainloh": min_gainloh_probes,
"mdel": min_mdel_probes, "mdup": min_mdup_probes, "mloh": min_mloh_probes},
size_bp={"del": _kb(min_del_size), "dup": _kb(min_dup_size), "loh": _kb(min_loh_size),
"gainloh": _kb(min_gainloh_size), "mdel": _kb(min_mdel_size), "mdup": _kb(min_mdup_size),
"mloh": _kb(min_mloh_size)},
max_mosaic_fraction=max_mosaic_fraction, min_mosaic_fraction=min_mosaic_fraction, filter_qual=filter_qual,
) # fmt: skip
rep = _reporter("cn call", out, log_json, debug, quiet)
opts = CnCallOptions(
out=out, bpm=bpm, model=model, gtcs=gtcs, summary=summary, fasta=fasta, thresholds=th, smoothing=smoothing,
cn_cap=cn_cap, gc_correct=gc_correct, unknown_sex_policy=unknown_sex_policy, gt_mode=gt_mode,
svlen_mode=svlen_mode, mask_bed=mask_bed, bgzip=not no_bgzip, mosaic_prior=mosaic_prior,
switch_d=switch_distance, probe_lrr_sd=probe_lrr_sd, threads=threads, force=force,
argv=sys.argv[1:],
) # fmt: skip
run_guarded(rep, lambda: run_cn_call(opts, rep))
@cn_app.command("annotate")
def cn_annotate_cmd(
vcfs: Annotated[Path, typer.Option("--vcfs", help="Output folder of cn call (required).")],
bundle: Annotated[
Path | None, typer.Option("--annotation-bundle", help="Bundle from scripts/build_annotation_bundle.sh.")
] = None,
out: Annotated[Path | None, typer.Option("--out", help="Output folder (default: --vcfs).")] = None,
min_qual: Annotated[int, typer.Option("--min-qual", min=0, max=60)] = 20,
size_basis: Annotated[str, typer.Option("--size-basis", help="effective|raw")] = "effective",
iscn_min_size: Annotated[int, typer.Option("--iscn-min-size", min=0, help="bp")] = 0,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""cn call output -> per-sample JSON report with ISCN 2020 strings, cytobands and genes (CLI-029)."""
from simurg.pipeline.cn import CnAnnotateOptions, run_cn_annotate
if size_basis not in ("effective", "raw"):
raise typer.BadParameter("--size-basis effective|raw")
rep = _reporter("cn annotate", out or vcfs, log_json, debug, quiet)
opts = CnAnnotateOptions(vcfs=vcfs, bundle=bundle, out=out, min_qual=min_qual, size_basis=size_basis,
iscn_min_size=iscn_min_size, force=force) # fmt: skip
run_guarded(rep, lambda: run_cn_annotate(opts, rep))
# --------------------------------------------------------------------------------------------------------
@app.command("train")
def train_cmd(
gtcs: Annotated[Path, typer.Option("--gtcs", help="Folder searched recursively for training GTCs (required).")],
bpm: Annotated[Path, typer.Option("--bpm", help="BPM manifest (required).")],
out: Annotated[Path, typer.Option("--out", help="Output cluster file (.egt, required).")],
min_samples: Annotated[int, typer.Option("--min-samples", min=1)] = 100,
max_samples: Annotated[int, typer.Option("--max-samples", min=1)] = 2000,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""ArrayTrain: train an EGT cluster file from a cohort's GTCs (plan §12)."""
from simurg.pipeline.train import TrainOptions, run_train
rep = _reporter("train", out.parent, log_json, debug, quiet)
o = TrainOptions(gtcs=gtcs, bpm=bpm, out=out, min_samples=min_samples, max_samples=max_samples, force=force)
run_guarded(rep, lambda: run_train(o, rep))
# --------------------------------------------------------------------------------------------------------
pgx_app = typer.Typer(help="Pharmacogenomics: targeted copy number, star alleles, phenotypes (v2).")
app.add_typer(pgx_app, name="pgx")
pgx_cn_app = typer.Typer(help="Targeted PGx copy number (PCN-001).")
pgx_app.add_typer(pgx_cn_app, name="copy-number")
pgx_db_app = typer.Typer(help="PGx allele-definition database (PGX-001).")
pgx_app.add_typer(pgx_db_app, name="db")
@pgx_db_app.command("build")
def pgx_db_build_cmd(
sources: Annotated[Path, typer.Option("--sources", help="Folder from scripts/pgx/fetch_sources.sh (required).")],
fasta: Annotated[Path, typer.Option("--fasta", help="GRCh38 FASTA for variant normalisation (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder for pgx_db-<schema>-<sha12>.sqlite.")] = Path("."),
manifests: Annotated[
list[Path] | None, typer.Option("--manifests", help="BPM manifest(s) for the probe map (repeatable).")
] = None,
csv: Annotated[list[Path] | None, typer.Option("--csv", help="CSV manifest(s), same order (indels).")] = None,
mask: Annotated[list[Path] | None, typer.Option("--mask", help="Probe mask TSV(s) (PGX-010).")] = None,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Build the PGx DB from public sources (CLI-035, PGX-002)."""
from simurg.pipeline.pgx_db import PgxDbOptions, run_db_build
rep = _reporter("pgx db build", out, log_json, debug, quiet)
o = PgxDbOptions(sources=sources, fasta=fasta, out=out, manifests=manifests or [], csvs=csv or [],
masks=mask or [], force=force) # fmt: skip
run_guarded(rep, lambda: run_db_build(o, rep))
pgx_star_app = typer.Typer(help="Star-allele calling (PGX-011).")
pgx_app.add_typer(pgx_star_app, name="star-allele")
@pgx_star_app.command("call")
def pgx_star_call_cmd(
vcfs: Annotated[list[Path], typer.Option("--vcfs", help="Folder(s) with SNV + PGx CN VCFs (repeatable).")],
db: Annotated[Path, typer.Option("--db", help="PGx DB from pgx db build (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
population: Annotated[str, typer.Option("--population", help="Frequency prior population.")] = "global",
max_candidates: Annotated[int, typer.Option("--max-candidates", min=1)] = 10,
feasible_ratio: Annotated[float, typer.Option("--feasible-ratio", min=0.0, max=1.0)] = 0.5,
low_gs: Annotated[float, typer.Option("--low-gs")] = 0.15,
no_cn: Annotated[bool, typer.Option("--no-cn", help="Run without PGx CN VCFs.")] = False,
allow_foreign_vcf: Annotated[bool, typer.Option("--allow-foreign-vcf")] = False,
gene: Annotated[list[str] | None, typer.Option("--gene", help="Restrict to gene(s).")] = None,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Call star-allele diplotypes (CLI-033, PGX-012 ... PGX-021)."""
from simurg.pipeline.pgx_star import StarOptions, run_star_call
rep = _reporter("pgx star-allele call", out, log_json, debug, quiet)
o = StarOptions(vcfs=vcfs, db=db, out=out, population=population, max_candidates=max_candidates,
feasible_ratio=feasible_ratio, low_gs=low_gs, no_cn=no_cn, allow_foreign_vcf=allow_foreign_vcf,
genes=gene or [], force=force) # fmt: skip
run_guarded(rep, lambda: run_star_call(o, rep))
def _pgx_phenotype(calls: Path, db: Path, out: Path | None, guideline: str, force: bool, log_json: bool,
debug: bool, quiet: bool, name: str) -> None: # fmt: skip
from simurg.pipeline.pgx_pheno import PhenoOptions, run_phenotype
if guideline not in ("cpic", "dpwg"):
raise typer.BadParameter("--guideline cpic|dpwg")
rep = _reporter(name, out or calls, log_json, debug, quiet)
o = PhenoOptions(calls=calls, db=db, out=out, guideline=guideline, force=force)
run_guarded(rep, lambda: run_phenotype(o, rep))
@pgx_app.command("phenotype")
def pgx_phenotype_cmd(
calls: Annotated[Path, typer.Option("--calls", help="Output folder of pgx star-allele call (required).")],
db: Annotated[Path, typer.Option("--db", help="PGx DB (required).")],
out: Annotated[Path | None, typer.Option("--out", help="Output folder (default: --calls).")] = None,
guideline: Annotated[str, typer.Option("--guideline", help="cpic|dpwg")] = "cpic",
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Phenotypes from star-allele calls: per-sample PGx JSON and diplotype_annotations.tsv (CLI-034)."""
_pgx_phenotype(calls, db, out, guideline, force, log_json, debug, quiet, "pgx phenotype")
@pgx_app.command("annotate", hidden=True)
def pgx_annotate_cmd(
calls: Annotated[Path, typer.Option("--calls")],
db: Annotated[Path, typer.Option("--db")],
out: Annotated[Path | None, typer.Option("--out")] = None,
guideline: Annotated[str, typer.Option("--guideline")] = "cpic",
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Alias of pgx phenotype (CLI-034)."""
_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")
def pgx_cn_train_cmd(
gtcs: Annotated[Path, typer.Option("--gtcs", help="Folder searched recursively for training GTCs (required).")],
bpm: Annotated[Path, typer.Option("--bpm", help="BPM manifest (required).")],
fasta: Annotated[Path, typer.Option("--fasta", help="GRCh38 FASTA (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder.")] = Path("."),
mask: Annotated[Path | None, typer.Option("--mask", help="Probe mask TSV (IN-052).")] = None,
regions: Annotated[Path | None, typer.Option("--regions", help="Region BED (default: built-in v1).")] = None,
summary: Annotated[Path | None, typer.Option("--summary", help="sample_summary.tsv (assay control).")] = None,
csv: Annotated[Path | None, typer.Option("--csv", help="CSV manifest (Assay Format → platform).")] = None,
platform: Annotated[str, typer.Option("--platform", help="auto|LCG|EX")] = "auto",
min_samples: Annotated[int, typer.Option("--min-samples", min=1)] = 96,
min_passing: Annotated[int, typer.Option("--min-passing", min=1)] = 90,
max_samples: Annotated[int, typer.Option("--max-samples", min=1)] = 3000,
train_lrr_sd_max: Annotated[float, typer.Option("--train-lrr-sd-max")] = 0.20,
train_call_rate_min: Annotated[float, typer.Option("--train-call-rate-min")] = 0.99,
train_assay_control_min: Annotated[float, typer.Option("--train-assay-control-min")] = 1.0,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Train a PGx CN model (CLI-032, PCN-005)."""
from simurg.pipeline.pgx_cn import PgxTrainOptions, run_train
if platform not in ("auto", "LCG", "EX"):
raise typer.BadParameter("--platform auto|LCG|EX")
rep = _reporter("pgx copy-number train", out, log_json, debug, quiet)
o = PgxTrainOptions(gtcs=gtcs, bpm=bpm, fasta=fasta, out=out, mask=mask, regions=regions, summary=summary,
platform=platform, csv=csv, min_samples=min_samples, min_passing=min_passing,
max_samples=max_samples,
lrr_sd_max=train_lrr_sd_max, call_rate_min=train_call_rate_min,
assay_control_min=train_assay_control_min, force=force) # fmt: skip
run_guarded(rep, lambda: run_train(o, rep))
@pgx_cn_app.command("call")
def pgx_cn_call_cmd(
gtcs: Annotated[Path, typer.Option("--gtcs", help="Folder searched recursively for GTC files (required).")],
model: Annotated[Path, typer.Option("--model", help="PGx CN model (.simurg-pgxcn, required).")],
fasta: Annotated[Path, typer.Option("--fasta", help="GRCh38 FASTA with alt contigs (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
min_batch: Annotated[int, typer.Option("--min-batch", min=1)] = 24,
min_passing: Annotated[int, typer.Option("--min-passing", min=1)] = 22,
recommended_passing: Annotated[int, typer.Option("--recommended-passing", min=1)] = 96,
lrr_sd_max: Annotated[float, typer.Option("--lrr-sd-max")] = 0.20,
cluster_mismatch: Annotated[str, typer.Option("--cluster-mismatch", help="error|warn")] = "error",
bafs_in_cn: Annotated[
bool, typer.Option("--bafs-in-cn", help="Experimental: add CN-consistent BAF evidence to the CN posterior.")
] = False,
no_bgzip: Annotated[bool, typer.Option("--no-bgzip")] = False,
force: ForceOpt = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Call targeted PGx copy number per region (CLI-031, PCN-009)."""
from simurg.pipeline.pgx_cn import PgxCallOptions, run_call
if cluster_mismatch not in ("error", "warn"):
raise typer.BadParameter("--cluster-mismatch error|warn")
if min_passing > min_batch:
raise typer.BadParameter("--min-passing must be <= --min-batch")
rep = _reporter("pgx copy-number call", out, log_json, debug, quiet)
o = PgxCallOptions(gtcs=gtcs, model=model, fasta=fasta, out=out, min_batch=min_batch, min_passing=min_passing,
recommended_passing=recommended_passing, lrr_sd_max=lrr_sd_max,
cluster_mismatch=cluster_mismatch, bafs_in_cn=bafs_in_cn, bgzip=not no_bgzip,
force=force) # fmt: skip
run_guarded(rep, lambda: run_call(o, rep))
# --------------------------------------------------------------------------------------------------------
methyl_app = typer.Typer(help="Methylation arrays: control QC, detection, beta values (MET-001).")
app.add_typer(methyl_app, name="methyl")
@methyl_app.command("qc")
def methyl_qc_cmd(
manifest: Annotated[Path, typer.Option("--manifest", help="Methylation CSV manifest (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
idats: Annotated[Path | None, typer.Option("--idats", help="Folder searched recursively for IDAT pairs.")] = None,
sheet: Annotated[Path | None, typer.Option("--sheet", help="Sample sheet (CSV).")] = None,
config: Annotated[Path | None, typer.Option("--config", help="QC threshold YAML.")] = None,
profile: Annotated[str, typer.Option("--profile", help="QC profile name, 'auto' or 'none'.")] = "auto",
threshold: Annotated[list[str] | None, typer.Option("--threshold", help="KEY=VALUE (repeatable).")] = None,
bg_offset: Annotated[float | None, typer.Option("--bg-offset", help="Background offset x (3000).")] = None,
bs_set: Annotated[str, typer.Option("--bs-conv-red-bg-set", help="U3-U5 (default) or U4-U6.")] = "U3-U5",
pval: Annotated[float, typer.Option("--pval-threshold", help="Detection p threshold.")] = 0.05,
beta_offset: Annotated[float, typer.Option("--beta-offset", help="alpha in M/(M+U+alpha).")] = 0.0,
dye_bias: Annotated[str, typer.Option("--dye-bias", help="linear|nonlinear")] = "linear",
min_prop: Annotated[
float | None, typer.Option("--min-prop-passing", help="Fail samples below this detection rate.")
] = None,
pca_full: Annotated[int, typer.Option("--pca-max-samples-full", min=2)] = 100,
pca_probes: Annotated[int, typer.Option("--pca-probes", min=2)] = 10_000,
threads: Annotated[int, typer.Option("--threads", min=0, max=1024, help="Worker processes (0 = auto).")] = 0,
force: Annotated[bool, typer.Option("--force", help="Replace existing outputs.")] = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Methylation control QC: 21 control metrics, detection p-values, noob/dye-bias corrected β (MET-001)."""
from simurg.pipeline.methyl import MethylQcOptions, run_methyl_qc
if dye_bias not in ("linear", "nonlinear"):
raise typer.BadParameter("--dye-bias must be linear or nonlinear")
if bs_set not in ("U3-U5", "U4-U6"):
raise typer.BadParameter("--bs-conv-red-bg-set must be U3-U5 or U4-U6")
if not 0 < pval <= 1:
raise typer.BadParameter("--pval-threshold must be in (0, 1]")
rep = _reporter("methyl qc", out, log_json, debug, quiet)
opts = MethylQcOptions(
out=out, manifest=manifest, idats=idats, sheet=sheet, config=config,
profile=None if profile == "none" else profile, thresholds=threshold or [], bg_offset=bg_offset,
bs_conv_red_bg_set=bs_set, pval_threshold=pval, beta_offset=beta_offset, dye_bias=dye_bias,
min_prop_passing=min_prop, pca_max_samples_full=pca_full, pca_probes=pca_probes, threads=threads,
force=force, argv=sys.argv[1:],
) # fmt: skip
run_guarded(rep, lambda: run_methyl_qc(opts, rep))
@methyl_app.command("convert-manifest")
def methyl_convert_cmd(
manifest: Annotated[Path, typer.Option("--manifest", help="Methylation CSV manifest (required).")],
out: Annotated[Path, typer.Option("--out", help="Output folder (required).")],
force: Annotated[bool, typer.Option("--force", help="Replace existing outputs.")] = False,
log_json: LogJson = False,
debug: Debug = False,
quiet: Quiet = False,
) -> None:
"""Methylation manifest -> probes.tsv and controls.tsv (MET-003)."""
from simurg.pipeline.methyl import run_convert_manifest
rep = _reporter("methyl convert-manifest", out, log_json, debug, quiet)
run_guarded(rep, lambda: run_convert_manifest(manifest, out, force, rep))
def _qc_cohort( def _qc_cohort(
command: str, command: str,
out: Path, out: Path,
+130
View File
@@ -0,0 +1,130 @@
"""CN event annotation: cytobands, genes, ISCN 2020 strings — spec CYTO-033 … CYTO-035. Pure functions, no I/O.
[DOC: ISCN 2020 (An International System for Human Cytogenomic Nomenclature), microarray nomenclature section;
UCSC cytoBand tables] Microarray form ``arr[<build>] <chr><band_start>[<band_end>](<S>_<E>)x<CN>`` per event, LOH as
``x2 hmz`` (gain-LOH ``x3 hmz``), mosaic as ``x<base>~<CN>``; normal results ``arr[<build>](1-22,X)x2`` (female),
``(1-22)x2,(XY)x1`` (male), ``(1-22)x2`` (sex unresolved). Traditional form ``del(<chr>)(<bands>)`` /
``dup(<chr>)(<bands>)`` for CNVs only.
"""
from __future__ import annotations
import bisect
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
CHROM_ORDER = {str(i): i for i in range(1, 23)} | {"X": 23, "Y": 24}
@dataclass
class Bands:
"""1-based inclusive band intervals per chromosome, sorted."""
starts: dict[str, list[int]]
ends: dict[str, list[int]]
names: dict[str, list[str]]
@classmethod
def from_ucsc(cls, text: str) -> Bands:
rows: dict[str, list[tuple[int, int, str]]] = {}
for line in text.splitlines():
f = line.split("\t")
if len(f) < 4:
continue
c = f[0][3:] if f[0].lower().startswith("chr") else f[0]
if "_" in c:
continue
rows.setdefault(c, []).append((int(f[1]) + 1, int(f[2]), f[3]))
s, e, n = {}, {}, {}
for c, v in rows.items():
v.sort()
s[c], e[c], n[c] = [x[0] for x in v], [x[1] for x in v], [x[2] for x in v]
return cls(s, e, n)
def band(self, chrom: str, pos: int) -> str:
st = self.starts.get(chrom)
if not st:
return ""
i = max(0, bisect.bisect_right(st, pos) - 1)
return self.names[chrom][i]
@dataclass
class Genes:
"""Transcripts: per chromosome sorted by start, with the gene label (HGNC symbol or Ensembl/RefSeq ID)."""
starts: dict[str, list[int]]
ends: dict[str, list[int]]
labels: dict[str, list[str]]
max_len: dict[str, int]
@classmethod
def from_rows(cls, rows: Sequence[tuple[str, int, int, str]]) -> Genes:
by: dict[str, list[tuple[int, int, str]]] = {}
for c, s, e, lab in rows:
by.setdefault(c, []).append((s, e, lab))
st, en, lb, ml = {}, {}, {}, {}
for c, v in by.items():
v.sort()
st[c], en[c], lb[c] = [x[0] for x in v], [x[1] for x in v], [x[2] for x in v]
ml[c] = max(x[1] - x[0] for x in v)
return cls(st, en, lb, ml)
def overlapping(self, chrom: str, start: int, end: int) -> list[str]:
"""CYTO-035: every transcript overlapping by ≥ 1 bp; labels deduplicated in transcript-start order."""
st = self.starts.get(chrom)
if not st:
return []
lo = bisect.bisect_left(st, start - self.max_len[chrom])
hi = bisect.bisect_right(st, end)
out: list[str] = []
seen: set[str] = set()
for i in range(lo, hi):
if self.ends[chrom][i] >= start and self.labels[chrom][i] not in seen:
seen.add(self.labels[chrom][i])
out.append(self.labels[chrom][i])
return out
def _band_span(bands: Bands, ev: dict[str, Any]) -> tuple[str, str]:
return bands.band(ev["chrom"], ev["start"]), bands.band(ev["chrom"], ev["end"])
def _chrom_label(c: str) -> str:
return c
def iscn_microarray_event(ev: dict[str, Any], bands: Bands) -> str:
b1, b2 = _band_span(bands, ev)
span = b1 if b1 == b2 else f"{b1}{b2}"
cn = int(ev["copy_number"])
if ev.get("mosaic"):
base = int(ev.get("base_cn", 2))
copies = f"x{base}~{cn if ev['call'] != 'MLOH' else base}"
if ev["call"] == "MLOH":
copies += " hmz"
elif ev["call_type"] == "LOH":
copies = f"x{cn} hmz"
else:
copies = f"x{cn}"
return f"{_chrom_label(ev['chrom'])}{span}({ev['start']}_{ev['end']}){copies}"
def iscn_traditional_event(ev: dict[str, Any], bands: Bands) -> str | None:
if ev["call_type"] not in ("DEL", "DUP"):
return None
b1, b2 = _band_span(bands, ev)
kind = "del" if ev["call_type"] == "DEL" else "dup"
return f"{kind}({ev['chrom']})({b1}{b2})"
def iscn_sample(events: list[dict[str, Any]], bands: Bands, build: str, sex: str) -> tuple[str, str]:
"""(traditional, microarray) strings for the reported events (already filtered)."""
evs = sorted(events, key=lambda e: (CHROM_ORDER.get(e["chrom"], 99), e["start"]))
if not evs:
normal = {"F": "(1-22,X)x2", "M": "(1-22)x2,(XY)x1"}.get(sex, "(1-22)x2")
return "", f"arr[{build}]{normal}"
micro = f"arr[{build}] " + ",".join(iscn_microarray_event(e, bands) for e in evs)
trad = ",".join(t for t in (iscn_traditional_event(e, bands) for e in evs) if t)
return trad, micro
Binary file not shown.
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
# centromeres, gvar/stalk bands, acrocentric p-arms; derived from UCSC hg19 cytoBand
1 121500000 142600000
2 90500000 96800000
3 87900000 98300000
4 48200000 52700000
5 46100000 50700000
6 58700000 63300000
7 58000000 61700000
8 43100000 48100000
9 47300000 65900000
10 38000000 42300000
11 51600000 55700000
12 33300000 38200000
13 0 19500000
14 0 19100000
15 0 20700000
16 34600000 47000000
17 22200000 25800000
18 15400000 19000000
19 20000000 32400000
20 25600000 29400000
21 0 14300000
22 0 17900000
X 58100000 63000000
Y 11600000 13400000
Y 28800000 59373566
+26
View File
@@ -0,0 +1,26 @@
# centromeres, gvar/stalk bands, acrocentric p-arms; derived from UCSC hg38 cytoBand
1 121700000 143200000
2 91800000 96000000
3 87800000 98600000
4 48200000 51800000
5 46100000 51400000
6 58500000 62600000
7 58100000 62100000
8 43200000 47200000
9 42200000 61500000
10 38000000 41600000
11 51000000 55800000
12 33200000 37800000
13 0 18900000
14 0 18200000
15 0 20500000
16 35300000 47000000
17 22700000 27400000
18 15400000 21500000
19 19900000 31900000
20 25700000 30400000
21 0 13000000
22 0 17400000
X 58100000 63800000
Y 10300000 10600000
Y 26600000 57227415
+290
View File
@@ -0,0 +1,290 @@
"""CN/LOH event extraction, statistics and filters — spec CYTO-006 … CYTO-021, CYTO-027. Pure functions, no I/O.
From a per-chromosome Viterbi path: runs of non-baseline states become events; statistics per CYTO-027; effective
size per CYTO-010 (gaps > 150 × the chromosome's median spacing replaced by the median); QUAL from the mean posterior
of the called state class (CYTO-012); mosaic fraction from the BAF split or, failing that, LRR (CYTO-018/019/042);
post-filters in the order of CYTO-015; per-type thresholds (CYTO-007/008) and promotion/low fraction (CYTO-020/021).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
import numpy.typing as npt
from simurg.cn.hmm import State
F64 = npt.NDArray[np.float64]
I64 = npt.NDArray[np.int64]
GAP_FACTOR = 150
MIN_MF_PROBES = 20
LOSS_EXPECTED_HETS = 3.0 # CYTO-041: expected heterozygous calls needed to call a loss complete
LOSS_HET_RATIO = 0.25 # CYTO-041: observed / expected heterozygous calls below which a loss is complete
def complete_loss(geno: npt.NDArray[np.uint8], pfb: F64) -> bool:
"""CYTO-041 [OURS]: a loss is complete when its called genotypes lack the heterozygotes the PFB predicts
(Σ 2p(1 − p) ≥ 3 expected, fewer than a quarter observed); a mosaic loss keeps its heterozygotes."""
called = geno > 0
p = np.clip(np.where(np.isfinite(pfb), pfb, 0.5), 0.0, 1.0)[called]
expected = float((2 * p * (1 - p)).sum())
return expected >= LOSS_EXPECTED_HETS and float((geno[called] == 2).sum()) < LOSS_HET_RATIO * expected
TYPES = ("del", "dup", "loh", "gainloh", "mdel", "mdup", "mloh")
DEFAULTS = {"del": (10, 25_000), "dup": (10, 50_000), "loh": (500, 3_000_000), "gainloh": (300, 500_000),
"mdel": (10, 25_000), "mdup": (10, 50_000), "mloh": (500, 3_000_000)} # fmt: skip
CALL_KEY = {"DEL": "del", "DUP": "dup", "LOH": "loh", "GAINLOH": "gainloh", "MDEL": "mdel", "MDUP": "mdup",
"MLOH": "mloh"} # fmt: skip
@dataclass
class Event:
chrom: str
start: int # first affected base S (1-based) = first probe position
end: int # last affected base E = last probe position
i0: int # probe index range in the chromosome arrays [i0, i1)
i1: int
call: str # DEL | DUP | LOH | GAINLOH | MDEL | MDUP | MLOH
cn: int
state: str
quality: float = 0.0
effective_size: int = 0
nr: float = math.nan
lrd: float = math.nan
baf_dev: float = math.nan
percent_het: float = math.nan
lrr_median: float = math.nan
mosaic_fraction: float | None = None
high_fraction: bool = False
filters: list[str] = field(default_factory=list)
flags: list[str] = field(default_factory=list)
@property
def mosaic(self) -> bool:
return self.call.startswith("M")
@property
def probes(self) -> int:
return self.i1 - self.i0
@property
def size(self) -> int:
return self.end - self.start + 1
@property
def svtype(self) -> str:
return {"MDEL": "DEL", "MDUP": "DUP", "MLOH": "LOH", "GAINLOH": "LOH"}.get(self.call, self.call)
@property
def loh_type(self) -> str | None:
if self.call == "LOH":
return "AOH"
if self.call == "GAINLOH":
return "AOH"
if self.call == "MLOH":
return "CNLOH"
return None
@property
def id_prefix(self) -> str:
return {"DEL": "DEL", "DUP": "DUP", "LOH": "AOH", "GAINLOH": "AOH", "MDEL": "MosaicDEL", "MDUP": "MosaicDUP",
"MLOH": "CNLOH"}[self.call] # fmt: skip
def median_spacing(pos: I64) -> float:
d = np.diff(np.sort(pos))
d = d[d > 0]
return float(np.median(d)) if len(d) else 1.0
def effective_size(pos: I64, m: float) -> int:
"""CYTO-010 for the probes of one event (sorted positions)."""
if len(pos) < 2:
return 1
s = np.diff(pos).astype(np.float64)
gap = s > GAP_FACTOR * m
return int((pos[-1] - pos[0] + 1) - float((s[gap] - m).sum()))
def extract(chrom: str, path: npt.NDArray[np.int32], states: list[State], pos: I64) -> list[Event]:
"""Runs of non-baseline states, merged across runs of the same call type (mosaic fraction steps)."""
out: list[Event] = []
if len(path) == 0:
return out
br = np.flatnonzero(np.diff(path) != 0) + 1
starts, ends = np.r_[0, br], np.r_[br, len(path)]
for s, e in zip(starts.tolist(), ends.tolist(), strict=True):
st = states[int(path[s])]
if not st.call:
continue
if out and out[-1].i1 == s and out[-1].call == st.call and out[-1].cn == st.cn:
out[-1].i1 = e
out[-1].end = int(pos[e - 1])
continue
out.append(Event(chrom, int(pos[s]), int(pos[e - 1]), s, e, st.call, st.cn, st.name))
return out
def _phi(x: float) -> float:
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
def _split_from_median(m: float) -> float:
"""δ ≥ 0 with median |N(±δ, 1)| = m (bisection; 0 when m ≤ 0.6745)."""
if _phi(m) - _phi(-m) <= 0.5:
return 0.0
lo, hi = 0.0, m + 10.0
for _ in range(60):
mid = 0.5 * (lo + hi)
if _phi(m - mid) - _phi(-m - mid) > 0.5:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
def _mf_from_baf(call: str, baf: F64, sigma: F64 | float = 0.0) -> float | None:
"""CYTO-018 with the CYTO-041 noise correction [OURS]. ``baf``: heterozygous-call BAF of the event; ``sigma``:
each probe's expected BAF noise (reference heterozygous spread × the sample's scale). With m = median(|BAF − 0.5|
/ σ), the split δ (in σ units) solves Φ(m − δ) − Φ(−m − δ) = ½ (the median of |N(±δ, 1)|; δ = 0 when
m ≤ 0.6745, i.e. no more spread than noise), and d = median(σ)·δ."""
sig = np.broadcast_to(np.asarray(sigma, dtype=np.float64), baf.shape)
ok = np.isfinite(baf) & (baf > 0.1) & (baf < 0.9) & np.isfinite(sig)
b, sg = baf[ok], sig[ok]
if len(b) < MIN_MF_PROBES:
return None
if float(np.median(sg)) > 0:
sg = np.maximum(sg, 1e-3)
d = float(np.median(sg)) * _split_from_median(float(np.median(np.abs(b - 0.5) / sg)))
else:
d = float(np.median(np.abs(b - 0.5)))
if call == "MDEL":
f = 2 * d / (0.5 + d)
elif call == "MDUP":
f = 2 * d / (0.5 - d) if d < 0.5 else 1.0
else:
f = 2 * d
return float(min(1.0, max(0.0, f)))
def _mf_from_lrr(call: str, lrr: F64, gamma: float) -> float | None:
x = lrr[np.isfinite(lrr)]
if len(x) < MIN_MF_PROBES or gamma <= 0:
return None
f = abs(2 ** (float(np.median(x)) / gamma) - 1)
return float(min(1.0, max(0.0, f)))
def annotate_stats(
ev: Event, pos: I64, lrr: F64, baf: F64, geno: npt.NDArray[np.uint8], post: F64, states: list[State], m: float,
gamma: float, haploid: bool, sigma: F64 | None = None, pfb: F64 | None = None,
) -> None: # fmt: skip
"""CYTO-010/012/018/027 statistics in place (``lrr`` unsmoothed)."""
sl = slice(ev.i0, ev.i1)
x = lrr[sl]
fx = x[np.isfinite(x)]
ev.nr = float(np.median(2.0**fx)) if len(fx) else math.nan
ev.lrd = float(np.std(fx)) if len(fx) > 1 else math.nan
ev.lrr_median = float(np.median(fx)) if len(fx) else math.nan
g = geno[sl]
called = g > 0
ev.percent_het = float(100.0 * (g == 2).sum() / called.sum()) if called.any() else math.nan
b = baf[sl][(g == 2) & np.isfinite(baf[sl])]
ev.baf_dev = float(np.std(b)) if len(b) > 1 else math.nan
ev.effective_size = effective_size(pos[sl], m)
cls = [j for j, st in enumerate(states) if st.call == ev.call]
p = float(post[sl][:, cls].sum(axis=1).mean()) if cls else 0.0
ev.quality = 60.0 if p >= 1.0 else float(min(60, max(0, round(-10 * math.log10(max(1e-300, 1 - p))))))
if ev.mosaic:
if not haploid and ev.call == "MDEL" and pfb is not None and complete_loss(g, pfb[sl]):
ev.mosaic_fraction = 1.0
return
hb = np.where(g == 2, baf[sl], np.nan) if sigma is not None else baf[sl]
mf = None if haploid else _mf_from_baf(ev.call, hb, 0.0 if sigma is None else sigma[sl])
ev.mosaic_fraction = mf if mf is not None else _mf_from_lrr(ev.call, x, gamma)
@dataclass
class Thresholds:
probes: dict[str, int]
size_bp: dict[str, int]
max_mosaic_fraction: float = 1.0
min_mosaic_fraction: float = 0.10
filter_qual: int = 10
lrr_direction_min_frac: float = 0.6
@classmethod
def defaults(cls) -> Thresholds:
return cls({k: v[0] for k, v in DEFAULTS.items()}, {k: v[1] for k, v in DEFAULTS.items()})
def as_header(self, extra: dict[str, object]) -> str:
parts = []
for k in ("del", "dup", "loh", "gainloh", "mdel", "mdup", "mloh"):
parts += [f"min_{k}_probes={self.probes[k]}", f"min_{k}_size_bp={self.size_bp[k]}"]
parts += [f"{k}={v}" for k, v in extra.items()]
return ";".join(parts)
def post_filter(events: list[Event], lrr_by_event: list[F64], th: Thresholds, counts: dict[str, int]) -> list[Event]:
"""CYTO-015 steps 1–3 and CYTO-020 promotion; per-type thresholds are applied by :func:`apply_thresholds`."""
kept: list[Event] = []
for ev, x in zip(events, lrr_by_event, strict=True):
fx = x[np.isfinite(x)]
if ev.call in ("DEL", "MDEL") and len(fx) and (fx < 0).mean() < th.lrr_direction_min_frac:
counts["lrr_direction"] = counts.get("lrr_direction", 0) + 1
continue
if ev.call in ("DUP", "MDUP", "GAINLOH") and len(fx) and (fx > 0).mean() < th.lrr_direction_min_frac:
counts["lrr_direction"] = counts.get("lrr_direction", 0) + 1
continue
kept.append(ev)
# short LOH between copy-neutral neighbours (no other event adjacent on either side)
out: list[Event] = []
for i, ev in enumerate(kept):
if ev.call in ("LOH", "MLOH") and ev.size < 500_000:
left = kept[i - 1] if i > 0 and kept[i - 1].chrom == ev.chrom else None
right = kept[i + 1] if i + 1 < len(kept) and kept[i + 1].chrom == ev.chrom else None
if not (left and left.i1 == ev.i0) and not (right and right.i0 == ev.i1):
counts["short_loh"] = counts.get("short_loh", 0) + 1
continue
out.append(ev)
merged: list[Event] = []
for ev in out:
prev = merged[-1] if merged else None
if (prev and prev.chrom == ev.chrom and prev.call == ev.call and prev.cn == ev.cn
and ev.i0 - prev.i1 <= 3 and (ev.start - prev.end) <= 0.1 * (ev.end - prev.start + 1)): # fmt: skip
prev.i1, prev.end = ev.i1, ev.end
counts["merged"] = counts.get("merged", 0) + 1
continue
merged.append(ev)
for ev in merged: # CYTO-020 promotion
if ev.mosaic and ev.mosaic_fraction is not None and ev.mosaic_fraction >= th.max_mosaic_fraction:
ev.call = {"MDEL": "DEL", "MDUP": "DUP", "MLOH": "LOH"}[ev.call]
ev.high_fraction = True
return merged
def apply_thresholds(events: list[Event], th: Thresholds, counts: dict[str, int]) -> list[Event]:
out = []
for ev in events:
k = CALL_KEY[ev.call]
if ev.probes < th.probes[k] or ev.effective_size < th.size_bp[k]:
counts["below_threshold"] = counts.get("below_threshold", 0) + 1
continue
out.append(ev)
return out
def set_filters(events: list[Event], th: Thresholds, sample_noisy: bool) -> None:
"""CYTO-013 FILTER values."""
for ev in events:
f = []
if ev.quality < th.filter_qual:
f.append(f"Q{th.filter_qual}")
if sample_noisy:
f.append("SampleQuality")
if ev.mosaic and ev.mosaic_fraction is not None and ev.mosaic_fraction < th.min_mosaic_fraction:
f.append("LowFraction")
ev.filters = f or ["PASS"]
+38
View File
@@ -0,0 +1,38 @@
"""GC-wave correction of LRR — spec CYTO-003, plan §10.1. Pure functions, no I/O.
[LIT: Diskin et al. 2008 NAR 36:e126] LRR is regressed on the probe-centred GC fractions (1 kb, 100 kb, 1 Mb) by
ordinary least squares over autosomal probes with |LRR| < 1 (CNV and outlier probes do not steer the fit); the GC
component (fitted value minus its mean) is subtracted. [OURS] GCWF is reported as the standard deviation of that
removed component, i.e. the size of the wave in LRR units.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
F64 = npt.NDArray[np.float64]
MIN_PROBES = 1000
@dataclass
class GcFit:
applied: bool
coef: F64 # intercept + one slope per window
gcwf: float
def gc_correct(lrr: F64, gc: npt.NDArray[np.float32], use: npt.NDArray[np.bool_]) -> tuple[F64, GcFit]:
"""Return corrected LRR (NaN stays NaN) and the fit; unchanged LRR when too few probes qualify."""
x = np.asarray(gc, dtype=np.float64)
ok = use & np.isfinite(lrr) & (np.abs(lrr) < 1.0) & np.isfinite(x).all(axis=1)
if ok.sum() < MIN_PROBES:
return lrr.copy(), GcFit(False, np.zeros(x.shape[1] + 1), 0.0)
xm = x[ok].mean(axis=0)
design = np.column_stack([np.ones(int(ok.sum())), x[ok] - xm])
coef, *_ = np.linalg.lstsq(design, lrr[ok], rcond=None)
fitted = np.where(np.isfinite(x).all(axis=1), (np.nan_to_num(x) - xm) @ coef[1:], 0.0)
out = lrr - fitted
return out, GcFit(True, coef, float(np.std(fitted[ok])))
+196
View File
@@ -0,0 +1,196 @@
"""Genome-wide CN/LOH/mosaic hidden Markov model — spec CYTO-004/005/012/018, plan §10.2. Pure functions, no I/O.
[LIT: Wang et al. 2007 Genome Res 17:1665 (PennCNV: LRR/BAF emissions weighted by population B-allele frequency,
distance-dependent transitions); Loh et al. 2018 Nature 559:350 (MoChA: mosaic BAF split); DOC: cnvPartition
technical note (reference LRR state means CN0 −5, CN1 −0.45, CN2 0, CN3 0.3, CN4 0.75)] [OURS: state grid, mixture
weights, switch model]
* Diploid regions: CN0, CN1, CN2, CN2-LOH, CN3, CN3-LOH, CN4 and mosaic loss / gain / CN-LOH at cell fractions
0.2, 0.4, 0.6, 0.8. Haploid regions (male X outside PAR, male Y): CN0, CN1, CN2 and mosaic gain/loss relative to
the region's median LRR.
* LRR ~ (1−ε)·N(µ_state, σ) + ε·U(−5, 3); σ = 1.4826·MAD of the sample's autosomal LRR (floor 0.08), scaled per
probe by the reference's LRR spread (CYTO-042); CN0 σ = 2.
Mosaic means interpolate linearly between the constitutional means by the cell fraction.
* BAF ~ (1−ε)·Σ_g w_g N(b_g, σ_b) + ε·U(0, 1); genotype weights from the probe's PFB p (binomial in the copy
number; LOH: {0: 1−p, 1: p}); mosaic heterozygotes split into the two bands 0.5 ± d(f).
* Transitions: switch probability 1 − exp(−d / D) for inter-probe distance d, D = 5·10⁸ bp; a switch lands on state
j with probability π_j (the same target distribution from every state).
* Viterbi path for segmentation; forward–backward posteriors for QUAL.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
import numpy.typing as npt
F64 = npt.NDArray[np.float64]
FRACTIONS = (0.2, 0.4, 0.6, 0.8)
LRR_MEAN = {0: -5.0, 1: -0.45, 2: 0.0, 3: 0.3, 4: 0.75}
EPS = 0.01
MOSAIC_PRIOR = 0.004 # total switch-target mass of the 12 diploid mosaic states
SWITCH_D = 5e8
@dataclass(frozen=True)
class State:
name: str
cn: int # copy number (baseline-relative for haploid regions: 0, 1, 2)
loh: bool
mosaic: str # "" | "loss" | "gain" | "cnloh"
fraction: float # cell fraction of mosaic states, 1.0 otherwise
call: str # "" (baseline) | DEL | DUP | LOH | GAINLOH | MDEL | MDUP | MLOH
lrr_mu: float
baf: tuple[tuple[str, float], ...] # (genotype-weight key, BAF mean)
prior: float
def _d_split(kind: str, f: float) -> float:
if kind == "loss":
return f / (2 * (2 - f))
if kind == "gain":
return f / (2 * (2 + f))
return f / 2
def diploid_states(mosaic_prior: float = MOSAIC_PRIOR) -> list[State]:
s = [
State("CN0", 0, False, "", 1.0, "DEL", LRR_MEAN[0], (), 0.01),
State("CN1", 1, False, "", 1.0, "DEL", LRR_MEAN[1], (("h0", 0.0), ("h1", 1.0)), 0.06),
State("CN2", 2, False, "", 1.0, "", 0.0, (("aa", 0.0), ("ab", 0.5), ("bb", 1.0)), 0.60),
State("CN2LOH", 2, True, "", 1.0, "LOH", 0.0, (("h0", 0.0), ("h1", 1.0)), 0.06),
State("CN3", 3, False, "", 1.0, "DUP", LRR_MEAN[3],
(("t0", 0.0), ("t1", 1 / 3), ("t2", 2 / 3), ("t3", 1.0)), 0.06),
State("CN3LOH", 3, True, "", 1.0, "GAINLOH", LRR_MEAN[3], (("h0", 0.0), ("h1", 1.0)), 0.02),
State("CN4", 4, False, "", 1.0, "DUP", LRR_MEAN[4],
(("q0", 0.0), ("q1", 0.25), ("q2", 0.5), ("q3", 0.75), ("q4", 1.0)), 0.03),
] # fmt: skip
for kind, call, mu_end in (("loss", "MDEL", LRR_MEAN[1]), ("gain", "MDUP", LRR_MEAN[3]), ("cnloh", "MLOH", 0.0)):
for f in FRACTIONS:
d = _d_split(kind, f)
cn = {"loss": 1, "gain": 3, "cnloh": 2}[kind]
bands = (("aa", 0.0), ("ab-", 0.5 - d), ("ab+", 0.5 + d), ("bb", 1.0))
s.append(State(f"M{kind}{f:.1f}", cn, kind == "cnloh", kind, f, call, mu_end * f, bands, mosaic_prior / 12))
return s
def haploid_states(mosaic_prior: float = MOSAIC_PRIOR) -> list[State]:
s = [
State("H0", 0, False, "", 1.0, "DEL", LRR_MEAN[0], (), 0.02),
State("H1", 1, False, "", 1.0, "", 0.0, (("h0", 0.0), ("h1", 1.0)), 0.80),
State("H2", 2, False, "", 1.0, "DUP", 0.45, (("h0", 0.0), ("h1", 1.0)), 0.06),
]
for f in FRACTIONS:
s.append(State(f"Hgain{f:.1f}", 2, False, "gain", f, "MDUP", 0.45 * f, (("h0", 0.0), ("h1", 1.0)),
mosaic_prior / 8)) # fmt: skip
s.append(State(f"Hloss{f:.1f}", 0, False, "loss", f, "MDEL", -1.0 * f, (("h0", 0.0), ("h1", 1.0)),
mosaic_prior / 8)) # fmt: skip
return s
def _geno_weights(key: str, p: F64) -> F64:
q = 1 - p
table = {
"aa": q * q, "ab": 2 * p * q, "bb": p * p, "ab-": p * q, "ab+": p * q,
"h0": q, "h1": p,
"t0": q**3, "t1": 3 * p * q * q, "t2": 3 * p * p * q, "t3": p**3,
"q0": q**4, "q1": 4 * p * q**3, "q2": 6 * p * p * q * q, "q3": 4 * p**3 * q, "q4": p**4,
} # fmt: skip
return table[key]
def _norm(x: F64, mu: float, sd: float | F64) -> F64:
return np.exp(-0.5 * ((x - mu) / sd) ** 2) / (sd * math.sqrt(2 * math.pi))
@dataclass
class Params:
lrr_sd: float | F64 # scalar or per probe (CYTO-042)
baf_sd: float
lrr_offset: float = 0.0 # added to every state mean (haploid regions: the region's median LRR)
switch_d: float = SWITCH_D
eps: float = EPS
extra: dict[str, float] = field(default_factory=dict)
def emissions(states: list[State], lrr: F64, baf: F64, pfb: F64, par: Params, het_sd: F64 | None = None) -> F64:
"""Per-probe, per-state emission likelihood (n × S); missing LRR/BAF contribute 1. ``het_sd``: per-probe spread
of heterozygous BAF in the reference; heterozygous components use max(σ_b, het_sd)."""
n = len(lrr)
out = np.ones((n, len(states)), dtype=np.float64)
lok = np.isfinite(lrr)
bok = np.isfinite(baf)
x = np.where(lok, lrr, 0.0)
b = np.clip(np.where(bok, baf, 0.5), 0.0, 1.0)
p = np.clip(np.where(np.isfinite(pfb), pfb, 0.5), 0.01, 0.99)
sd_het: float | F64 = (
np.maximum(par.baf_sd, np.nan_to_num(het_sd, nan=par.baf_sd)) if het_sd is not None else par.baf_sd
)
for j, st in enumerate(states):
sd = 2.0 if st.cn == 0 and not st.mosaic else par.lrr_sd
le = (1 - par.eps) * _norm(x, st.lrr_mu + par.lrr_offset, sd) + par.eps / 8.0
if st.baf:
be = np.zeros(n)
for key, mean in st.baf:
bsd = par.baf_sd if mean in (0.0, 1.0) or het_sd is None else sd_het
be += _geno_weights(key, p) * _norm(b, mean, bsd)
be = (1 - par.eps) * be + par.eps
else:
be = np.ones(n)
out[:, j] = np.where(lok, le, 1.0) * np.where(bok, be, 1.0)
return out
def switch_probs(pos: npt.NDArray[np.int64], switch_d: float = SWITCH_D) -> F64:
"""Switch probability before each probe (the first probe gets 1: it draws from the prior)."""
d = np.diff(np.asarray(pos, dtype=np.float64), prepend=np.nan)
p = 1.0 - np.exp(-np.maximum(d, 0.0) / switch_d)
p[0] = 1.0
return np.clip(np.nan_to_num(p, nan=1.0), 1e-12, 1.0)
def forward_backward(e: F64, sw: F64, prior: F64) -> F64:
"""Posterior state probabilities (n × S) under the switch model."""
n, s = e.shape
alpha = np.empty((n, s))
c = np.empty(n)
a = prior * e[0]
c[0] = a.sum() or 1e-300
alpha[0] = a / c[0]
for t in range(1, n):
a = ((1 - sw[t]) * alpha[t - 1] + sw[t] * prior) * e[t]
c[t] = a.sum() or 1e-300
alpha[t] = a / c[t]
beta = np.ones(s)
post = np.empty((n, s))
post[n - 1] = alpha[n - 1]
for t in range(n - 1, 0, -1):
eb = e[t] * beta
beta = ((1 - sw[t]) * eb + sw[t] * float(prior @ eb)) / c[t]
g = alpha[t - 1] * beta
post[t - 1] = g / (g.sum() or 1e-300)
return post
def viterbi(e: F64, sw: F64, prior: F64) -> npt.NDArray[np.int32]:
"""Most likely state path under the switch model (log space)."""
n, s = e.shape
le = np.log(np.maximum(e, 1e-300))
lp = np.log(np.maximum(prior, 1e-300))
back = np.empty((n, s), dtype=np.int32)
ld = lp + le[0]
idx = np.arange(s, dtype=np.int32)
for t in range(1, n):
stay = np.log(np.maximum(1 - sw[t] + sw[t] * prior, 1e-300)) + ld
best = int(np.argmax(ld))
jump = math.log(max(sw[t], 1e-300)) + lp + ld[best]
take_jump = jump > stay
back[t] = np.where(take_jump, best, idx)
ld = np.where(take_jump, jump, stay) + le[t]
path = np.empty(n, dtype=np.int32)
path[-1] = int(np.argmax(ld))
for t in range(n - 1, 0, -1):
path[t - 1] = back[t, path[t]]
return path
+107
View File
@@ -0,0 +1,107 @@
"""Cyto model covariates — spec CYTO-038, IN-051 (plan §10.1). Pure functions, no I/O.
GC fraction of windows centred on each probe [LIT: Diskin et al. 2008 NAR 36:e126 (GC-wave), windows 1 kb / 100 kb /
1 Mb]: (G+C) / (A+C+G+T) over the window, N bases excluded; NaN when the window has no called base. Population
B-allele frequency per probe (PFB) [LIT: Wang et al. 2007 Genome Res 17:1665 (PennCNV)] from cluster-file counts:
(n_AB + 2·n_BB) / (2·N); 0.5 when unknown.
"""
from __future__ import annotations
import numpy as np
import numpy.typing as npt
GC_WINDOWS = (1_000, 100_000, 1_000_000)
_C, _G, _A, _T = ord("C"), ord("G"), ord("A"), ord("T")
def gc_fractions(
seq: npt.NDArray[np.uint8], positions: npt.NDArray[np.int64], windows: tuple[int, ...] = GC_WINDOWS
) -> npt.NDArray[np.float32]:
"""GC fraction per (probe, window) for 1-based ``positions`` on one contig (upper-case ASCII ``seq``)."""
n = len(seq)
gc = np.zeros(n + 1, dtype=np.int32)
called = np.zeros(n + 1, dtype=np.int32)
step = 1 << 24
run_gc = run_called = 0
for s in range(0, n, step): # chunked cumulative sums keep temporaries small
chunk = np.asarray(seq[s : s + step])
g = (chunk == _C) | (chunk == _G)
c = g | (chunk == _A) | (chunk == _T)
cg = np.cumsum(g, dtype=np.int32) + run_gc
cc = np.cumsum(c, dtype=np.int32) + run_called
gc[s + 1 : s + 1 + len(chunk)] = cg
called[s + 1 : s + 1 + len(chunk)] = cc
run_gc, run_called = int(cg[-1]), int(cc[-1])
out = np.full((len(positions), len(windows)), np.nan, dtype=np.float32)
centre = np.asarray(positions, dtype=np.int64) - 1 # 0-based
for j, w in enumerate(windows):
lo = np.clip(centre - w // 2, 0, n)
hi = np.clip(centre - w // 2 + w, 0, n)
num = gc[hi] - gc[lo]
den = called[hi] - called[lo]
ok = den > 0
out[ok, j] = num[ok] / den[ok]
return out
def population_bfreq(counts: npt.NDArray[np.int32] | None, n_loci: int) -> npt.NDArray[np.float32]:
"""PFB from cluster counts (n, 3) = (AA, AB, BB); 0.5 where there are no counts."""
if counts is None:
return np.full(n_loci, 0.5, dtype=np.float32)
c = np.asarray(counts, dtype=np.float64)
tot = c.sum(axis=1)
with np.errstate(invalid="ignore", divide="ignore"):
p = (c[:, 1] + 2 * c[:, 2]) / (2 * tot)
return np.where(tot > 0, p, 0.5).astype(np.float32)
def reference_stats(
lrr: npt.NDArray[np.float32], baf: npt.NDArray[np.float32]
) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32], npt.NDArray[np.int32]]:
"""Batch reference (plan §10.1) from a samples × probes stack of normal samples: per-probe median LRR, per-probe
median heterozygous BAF (samples with 0.2 < BAF < 0.8; 0.5 when none) and the number of such samples."""
with np.errstate(all="ignore"):
med_lrr = np.nanmedian(np.where(np.isfinite(lrr), lrr, np.nan), axis=0)
het = np.where((baf > 0.2) & (baf < 0.8), baf, np.nan)
n_het = np.isfinite(het).sum(axis=0).astype(np.int32)
het_c = np.where(n_het > 0, np.nanmedian(het, axis=0), 0.5)
return (
np.nan_to_num(med_lrr, nan=0.0).astype(np.float32),
np.nan_to_num(het_c, nan=0.5).astype(np.float32),
n_het,
)
def reference_het_spread(baf: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
"""Per-probe robust SD (1.4826·MAD) of heterozygous BAF (0.2 < BAF < 0.8) across reference samples; NaN when
fewer than 5 heterozygous samples."""
het = np.where((baf > 0.2) & (baf < 0.8), baf, np.nan)
with np.errstate(all="ignore"):
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
med = np.nanmedian(het, axis=0)
mad = np.nanmedian(np.abs(het - med), axis=0)
n = np.isfinite(het).sum(axis=0)
return np.where(n >= 5, 1.4826 * mad, np.nan).astype(np.float32)
def reference_lrr_spread(lrr: npt.NDArray[np.float32], ref_lrr: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
"""Per-probe robust SD (1.4826·MAD) of LRR across reference samples after removing each sample's median and the
probe's reference median [OURS; per-probe noise as in PennCNV's per-marker SD, LIT: Wang et al. 2007]; NaN with
fewer than 5 finite values."""
import warnings
out = np.full(lrr.shape[1], np.nan, dtype=np.float32)
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
smed = np.nanmedian(np.where(np.isfinite(lrr), lrr, np.nan), axis=1, keepdims=True).astype(np.float32)
for j in range(0, lrr.shape[1], 20_000): # column chunks keep the temporaries small
x = np.where(np.isfinite(lrr[:, j : j + 20_000]), lrr[:, j : j + 20_000], np.nan) - smed
x -= ref_lrr[None, j : j + 20_000]
mad = np.nanmedian(np.abs(x - np.nanmedian(x, axis=0)), axis=0)
n = np.isfinite(x).sum(axis=0)
out[j : j + 20_000] = np.where(n >= 5, 1.4826 * mad, np.nan)
return out
+75 -1
View File
@@ -116,7 +116,6 @@ CATALOGUE: dict[str, MessageDef] = dict(
_m("W_CTRL_MISSING", "warning", "Controls needed by a metric are absent; metric not evaluable", "QC-007"), _m("W_CTRL_MISSING", "warning", "Controls needed by a metric are absent; metric not evaluable", "QC-007"),
_m("W_CTRL_ZERO_DIVISOR", "warning", "Control metric has a zero denominator (NaN)", "QC-008"), _m("W_CTRL_ZERO_DIVISOR", "warning", "Control metric has a zero denominator (NaN)", "QC-008"),
_m("W_CTRL_DEFS_DIFFER", "warning", "BPM and CSV control definitions differ; the BPM wins", "QC-001"), _m("W_CTRL_DEFS_DIFFER", "warning", "BPM and CSV control definitions differ; the BPM wins", "QC-001"),
_m("E_QC_METHYLATION", "error", "Methylation control metrics are not available yet (v2)", "QC-006"),
_m("E_QC_NO_DATASETS", "error", "No QC dataset folder found", "QC-041"), _m("E_QC_NO_DATASETS", "error", "No QC dataset folder found", "QC-041"),
_m("E_QC_DATASET_INCOMPLETE", "error", "Dataset folder lacks a required file", "QC-041"), _m("E_QC_DATASET_INCOMPLETE", "error", "Dataset folder lacks a required file", "QC-041"),
_m("E_QC_LABEL_COUNT", "error", "Number of labels does not match number of datasets", "QC-042"), _m("E_QC_LABEL_COUNT", "error", "Number of labels does not match number of datasets", "QC-042"),
@@ -136,6 +135,81 @@ CATALOGUE: dict[str, MessageDef] = dict(
_m("W_QC_PEDIGREE", "warning", "Parent1/Parent2/Replicate value does not name a sample in the set", "IN-018"), _m("W_QC_PEDIGREE", "warning", "Parent1/Parent2/Replicate value does not name a sample in the set", "IN-018"),
_m("W_QC_NO_BAF", "warning", "GTC has no B-allele frequencies; contamination not estimated", "QC-064"), _m("W_QC_NO_BAF", "warning", "GTC has no B-allele frequencies; contamination not estimated", "QC-064"),
_m("W_QC_TOO_FEW", "warning", "Too few samples or sites for a cohort QC statistic", "QC-026"), _m("W_QC_TOO_FEW", "warning", "Too few samples or sites for a cohort QC statistic", "QC-026"),
# --- cytogenetics CNV/LOH (CYTO-001 .. CYTO-040) -------------------------------------------------
_m("E_MODEL_MANIFEST_MISMATCH", "error", "Model was built for a different manifest probe set", "IN-051"),
_m("E_MODEL_VERSION", "error", "Model schema is too old; rebuild it", "IN-051"),
_m("W_CN_PROBE_FLOOR", "warning", "Minimum probe count below 10 raised to 10", "CYTO-008"),
_m(
"W_CN_THRESHOLD_EXCEEDS_GENOME",
"warning",
"Size threshold exceeds the effective genome; no events",
"CYTO-009",
),
_m("I_CN_SEX_RESOLVED", "info", "Sample sex updated from U by the caller", "CYTO-024"),
_m("E_CN_SEX_UNRESOLVED", "error", "Sample sex unresolved and --unknown-sex-policy error", "CYTO-024"),
_m("W_CN_NO_FASTA", "warning", "No FASTA: VCF REF padding bases written as N", "CYTO-029"),
_m("E_CN_SIDECAR_MISSING", "error", "cn_call_manifest.json not found in the cn call output", "CYTO-032"),
_m("E_CN_VCF_UNINDEXED", "error", "CNV VCFs must be bgzipped and indexed for annotate", "CYTO-032"),
_m("E_CN_FAILED", "error", "CN calling failed for the sample", "CYTO-040"),
# --- targeted PGx copy number (PCN-001 .. PCN-017) ---------------------------------------------------
_m("E_PGX_BATCH_TOO_SMALL", "error", "Too few (passing) samples in the batch for PGx CN calling", "PCN-004"),
_m("W_PGX_BATCH_BELOW_RECOMMENDED", "warning", "Fewer passing samples than recommended", "PCN-004"),
_m("E_PGX_TRAIN_TOO_SMALL", "error", "Too few (passing) samples to train a PGx CN model", "PCN-005"),
_m(
"E_MODEL_CLUSTER_MISMATCH",
"error",
"Input GTCs were called with another cluster file than the model",
"IN-050",
),
_m(
"W_MODEL_CLUSTER_MISMATCH",
"warning",
"Input GTCs were called with another cluster file than the model",
"IN-050",
),
_m("W_PGX_REGION_FEW_PROBES", "warning", "PGx CN region has fewer than 10 unmasked probes", "PCN-008"),
_m("W_PGX_NO_ALT_CONTIG", "warning", "FASTA lacks a contig carrying a PGx region; REF written as N", "IN-062"),
_m("W_PGX_BATCH_COMPOSITION", "warning", "Batch spans several lots/batches or > 14 days of scans", "PCN-006"),
_m("E_PGX_CN_FAILED", "error", "PGx CN calling failed for the sample", "PCN-002"),
_m("E_PGX_CN_MISSING", "error", "A sample has no PGx CN VCF (give --no-cn to run without)", "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_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_PLATFORM_UNKNOWN", "error", "PGx CN platform (LCG/EX) unknown: give --csv or --platform", "CLI-032"),
_m("E_TRAIN_TOO_SMALL", "error", "Too few GTCs to train a cluster file", "plan §12"),
_m("E_PGX_SOURCE", "error", "PGx source tables missing; run scripts/pgx/fetch_sources.sh", "PGX-001"),
# --- methylation control QC (MET-001 .. MET-018) -------------------------------------------------
_m("E_METH_NO_ASSAY", "error", "Probe section not found", "MET-002"),
_m("E_METH_NO_CONTROLS", "error", "Missing control probes in manifest", "MET-002"),
_m("E_METH_MISSING_COLUMNS", "error", "Missing required columns in the methylation manifest", "MET-002"),
_m(
"E_METH_CONTROLS_FORMAT",
"error",
"Controls not formatted correctly. Must have 4 columns (Address,Type,Color_Channel,Name)",
"MET-002",
),
_m("E_METH_NO_IDATS", "error", "No IDATs found", "MET-004"),
_m(
"W_METH_MISSING_SAMPLE",
"warning",
"Missing sample: IDATs absent; the sample fails, the run continues",
"MET-015",
),
_m("E_METH_PREP_FAILED", "error", "Prep failed (sample)", "MET-010"),
_m(
"W_METH_MISSING_CONTROL_TYPES",
"warning",
"Control types missing from the manifest; metric set to NA",
"MET-009",
),
_m(
"W_METH_MISSING_CONTROL_NAMES",
"warning",
"Control names missing from the manifest; metric set to NA",
"MET-009",
),
_m("W_METH_NO_FEATURES", "warning", "No features, skipping PCA plot", "MET-013"),
] ]
) )
+49
View File
@@ -233,3 +233,52 @@ def _parse_body(
trailer_floats=trailer, trailer_floats=trailer,
extra_per_record=extra, extra_per_record=extra,
) )
def write_egt(
path: str | Path,
names: list[str],
addresses: npt.NDArray[np.int32],
n: npt.NDArray[np.int32],
theta_mean: npt.NDArray[np.float32],
theta_dev: npt.NDArray[np.float32],
r_mean: npt.NDArray[np.float32],
r_dev: npt.NDArray[np.float32],
cluster_sep: npt.NDArray[np.float32],
total_score: npt.NDArray[np.float32],
manifest_name: str,
*,
cluster_version: str = "",
date_created: str = "",
normalization_version: str = "1.2.0",
) -> None:
"""Write an EGT v3, data-block version 9 file (plan §12 step 5): 15 extra floats per record (the first is the
intensity threshold, written as 0), per-record scores, genotypes, names, addresses, counts and the dbv-9 trailer.
Readable by this reader, BeadArrayFiles and gtc2vcf."""
from simurg.formats.binutil import BinaryWriter
k = len(names)
w = BinaryWriter()
w.i32(3)
for s in ("ArrayTrain", cluster_version, cluster_version, normalization_version, date_created):
w.string(s)
w.u8(1)
w.string(manifest_name)
w.i32(9)
w.string("")
w.i32(k)
rec = np.zeros(k, dtype=_record_dtype(15))
rec["n"], rec["r_dev"], rec["r_mean"] = n, r_dev, r_mean
rec["theta_dev"], rec["theta_mean"] = theta_dev, theta_mean
w.write(rec.tobytes())
sc = np.zeros(k, dtype=_SCORE_DTYPE)
sc["cluster_sep"], sc["total_score"], sc["original_score"] = cluster_sep, total_score, total_score
w.write(sc.tobytes())
for _ in range(k):
w.string("")
for nm in names:
w.string(nm)
w.array(np.asarray(addresses, np.int32), "<i4")
w.array(np.asarray(n, np.int32).ravel(), "<i4")
w.array(np.zeros(k, np.float32), "<f4")
Path(path).write_bytes(w.getvalue())
+157
View File
@@ -0,0 +1,157 @@
"""Methylation CSV manifest (EPIC v1/v2, MSA and custom designs) — spec MET-002, MET-003; plan §6.3.
Sections: ``[Heading]`` (key,value), ``[Assay]`` (header row + one row per probe; needs ``IlmnID``, ``AddressA_ID``,
``AddressB_ID``, ``Color_Channel``) and ``[Controls]`` (``Address,Type,Color_Channel,Name``, no header row; the
vendor files pad lines with empty fields). Infinium I probes have an ``AddressB_ID``; the colour channel is read
from ``Color_Channel`` (``Red``/``Grn``), Infinium II probes leave both empty.
[DOC: Illumina methylation manifest column descriptions; layout of the open-source toolkits' manifests (MET-003)]
"""
from __future__ import annotations
import csv
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import numpy.typing as npt
from simurg.core.errors import FormatError
from simurg.formats.csv_manifest import _open_text
REQUIRED = ("IlmnID", "AddressA_ID", "AddressB_ID", "Color_Channel")
@dataclass
class MethylControl:
address: int
type: str
color_channel: str
name: str
@property
def probe_id(self) -> str:
return f"ctl_{self.address}"
@dataclass
class MethylManifest:
path: str
heading: dict[str, str]
probe_ids: list[str]
u: npt.NDArray[np.int64] # AddressA_ID
m: npt.NDArray[np.int64] # AddressB_ID; 0 for Infinium II
col: npt.NDArray[np.str_] # "R" / "G" for Infinium I, "" for Infinium II
controls: list[MethylControl] = field(default_factory=list)
@property
def num_probes(self) -> int:
return len(self.probe_ids)
@property
def type_i(self) -> npt.NDArray[np.bool_]:
return np.asarray(self.m != 0, dtype=np.bool_)
def analytical(self) -> npt.NDArray[np.bool_]:
"""CG and SNP probes (MET-011): IDs starting with ``cg`` or ``rs``."""
return np.array([p[:2] in ("cg", "rs") for p in self.probe_ids], dtype=np.bool_)
def _address(s: str) -> int:
s = s.strip()
return int(s) if s else 0
def _channel(s: str, *, infinium_i: bool) -> str:
c = s.strip().casefold()
if not infinium_i:
return ""
if c in ("red", "r"):
return "R"
if c in ("grn", "green", "g"):
return "G"
return "?"
def read_methyl_manifest(path: str | Path) -> MethylManifest:
"""Parse a methylation CSV manifest (plain or gzipped). Raises the MET-002 errors."""
p = Path(path)
spath = str(p)
heading: dict[str, str] = {}
header: list[str] | None = None
idx: dict[str, int] = {}
ids: list[str] = []
u: list[int] = []
m: list[int] = []
col: list[str] = []
controls: list[MethylControl] = []
section = None
saw_assay = saw_controls = False
with _open_text(p) as fh:
for row in csv.reader(fh):
if not row or not any(c.strip() for c in row):
continue
first = row[0].strip()
if first.startswith("[") and first.endswith("]"):
section = first[1:-1].strip().casefold()
saw_assay |= section == "assay"
saw_controls |= section == "controls"
continue
if section == "heading":
heading[first] = row[1].strip() if len(row) > 1 else ""
elif section == "assay":
if header is None:
header = [c.strip() for c in row]
idx = {c: i for i, c in enumerate(header)}
miss = [c for c in REQUIRED if c not in idx]
if miss:
msg = f"{spath}: Missing required columns: {', '.join(miss)}"
raise FormatError("E_METH_MISSING_COLUMNS", msg, path=spath)
iu, im, ic, ii = idx["AddressA_ID"], idx["AddressB_ID"], idx["Color_Channel"], idx["IlmnID"]
continue
ids.append(row[ii].strip())
a, b = _address(row[iu]), _address(row[im]) if im < len(row) else 0
u.append(a)
m.append(b)
col.append(_channel(row[ic] if ic < len(row) else "", infinium_i=b != 0))
elif section == "controls":
fields = list(row)
while fields and not fields[-1].strip():
fields.pop()
if len(fields) < 4: # vendor EPIC files add a 5th field ("AVG") to some NEGATIVE lines (K-026)
raise FormatError(
"E_METH_CONTROLS_FORMAT",
f"{spath}: Controls not formatted correctly. Must have 4 columns "
f"(Address,Type,Color_Channel,Name): {','.join(fields)[:80]!r}",
path=spath,
)
addr, typ, colour, name = (f.strip() for f in fields[:4])
controls.append(MethylControl(_address(addr), typ, colour, name))
if not saw_assay or header is None:
raise FormatError("E_METH_NO_ASSAY", f"{spath}: Probe section not found", path=spath)
if not saw_controls or not controls:
raise FormatError("E_METH_NO_CONTROLS", f"{spath}: Missing control probes in manifest", path=spath)
return MethylManifest(
path=spath,
heading=heading,
probe_ids=ids,
u=np.asarray(u, dtype=np.int64),
m=np.asarray(m, dtype=np.int64),
col=np.asarray(col, dtype="<U1"),
controls=controls,
)
def write_probe_tables(man: MethylManifest, out: Path) -> tuple[Path, Path]:
"""MET-003: ``probes.tsv`` (probe_id, u, m, col; ``NA`` for Infinium II) and ``controls.tsv``."""
out.mkdir(parents=True, exist_ok=True)
probes, ctl = out / "probes.tsv", out / "controls.tsv"
with probes.open("w", encoding="utf-8", newline="\n") as fh:
fh.write("probe_id\tu\tm\tcol\n")
for pid, a, b, c in zip(man.probe_ids, man.u.tolist(), man.m.tolist(), man.col.tolist(), strict=True):
fh.write(f"{pid}\t{a}\t{b if b else 'NA'}\t{c or 'NA'}\n")
with ctl.open("w", encoding="utf-8", newline="\n") as fh:
fh.write("address\ttype\tcolor_channel\tname\tprobe_id\n")
for c in man.controls:
fh.write(f"{c.address}\t{c.type}\t{c.color_channel}\t{c.name}\t{c.probe_id}\n")
return probes, ctl
+8 -2
View File
@@ -15,7 +15,12 @@ from pathlib import Path
from simurg.core.errors import SimurgError from simurg.core.errors import SimurgError
MAX_LEN = 500 MAX_LEN = 500
KEY_PAIRS = (("SentrixBarcode_A", "SentrixPosition_A"), ("beadChipName", "sampleSectionName")) # the third pair is the methylation toolkits' convention (minfi/sesame sheets), accepted for MET-004 (K-026)
KEY_PAIRS = (
("SentrixBarcode_A", "SentrixPosition_A"),
("beadChipName", "sampleSectionName"),
("Sentrix_ID", "Sentrix_Position"),
)
RECOGNISED = ( RECOGNISED = (
"Sample_ID", "Sample_ID",
"Sample_Name", "Sample_Name",
@@ -246,7 +251,8 @@ def parse_samplesheet(path: str | Path, *, on_duplicate_sample: str = "error") -
raise _err( raise _err(
"E_SHEET_NO_KEY_COLUMNS", "E_SHEET_NO_KEY_COLUMNS",
spath, spath,
"missing SentrixBarcode_A/SentrixPosition_A (or beadChipName/sampleSectionName) columns", "missing SentrixBarcode_A/SentrixPosition_A (or beadChipName/sampleSectionName, "
"Sentrix_ID/Sentrix_Position) columns",
) )
recognised = {fold(c) for c in RECOGNISED} | {fold(key[0]), fold(key[1])} recognised = {fold(c) for c in RECOGNISED} | {fold(key[0]), fold(key[1])}
+232
View File
@@ -0,0 +1,232 @@
"""Targeted PGx copy number — spec PCN-001, PCN-009. Pure functions, no I/O.
Region value x = median over the region's unmasked probes of (sample-centred LRR − the probe's training reference),
minus the sample's scale (median of the same quantity over non-target autosomal probes).
CN mixture [OURS; array intensity compression as in cnvPartition/PennCNV, LIT: Wang et al. 2007 Genome Res 17:1665]:
component means µ_c = δ_r + γ_r · log2((c + 1) / 3) for CN c = 0 … 5 — a probe signal proportional to (c + β) with
a background β = 1 copy-equivalent, compressed by γ_r. δ_r (the region's CN-2 location), γ_r, σ_r and the weights
π_r are per region; γ_r has a Gaussian prior N(0.25, 0.08²). CN 0 may sit lower than the template (its own shift
ε₀ ≤ 0 and SD): GSTT1 probes drop by ~2 at CN 0, GSTM1 probes by ~0.3. The weights are constrained to Hardy–Weinberg
proportions of haplotype copy numbers (0 … 3 copies per haplotype, Dirichlet prior favouring one copy). Fitted by
EM from a grid of starts (each observed cluster labelled CN 0 … 4, CN 0 on the template or on the lowest values).
HWE and the template's spacing ratios identify the labels in multi-cluster regions (GSTM1 0/1/2 rather than 1/2/3,
which would imply unobserved CN-0 samples). On GSA v4 PGx CNV probes γ_r ≈ 0.19–0.56 (K-028).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
F64 = npt.NDArray[np.float64]
CN_MAX = 5 # "5 or more"
CNS = np.arange(CN_MAX + 1)
TEMPLATE = np.log2((CNS + 1) / 3.0)
HAP_ALPHA = np.array([1.2, 3.0, 1.2, 1.05]) # Dirichlet prior on haplotype copy frequencies f_0..f_3 (f_1 favoured)
SIGMA_FLOOR = 0.012
GAMMA_PRIOR = (0.25, 0.08) # mean, SD
GAMMA_RANGE = (0.15, 0.80)
PI_FLOOR = 0.002
CN0_PRIOR_N = 2.0
@dataclass(frozen=True)
class Region:
chrom: str
start0: int
end: int
name: str
gene: str
def parse_regions(text: str) -> list[Region]:
"""PCN-001 BED: ``chrom start0 end region gene``; ``#`` comments."""
out = []
for ln in text.splitlines():
if not ln.strip() or ln.startswith("#"):
continue
c, s, e, r, g = ln.split("\t")[:5]
out.append(Region(c, int(s), int(e), r, g))
return out
@dataclass
class RegionFit:
delta: float
gamma: float
sigma: float
weights: list[float]
n: int
cn0_shift: float = 0.0 # ≤ 0: extra drop of the CN-0 component below the template (probe-specific background)
cn0_sigma: float = 0.0 # SD of the CN-0 component (≥ sigma; 0 = sigma)
def means(self) -> F64:
m = self.delta + self.gamma * TEMPLATE
m[0] += self.cn0_shift
return m
def sds(self) -> F64:
s = np.full(len(CNS), self.sigma)
s[0] = max(self.sigma, self.cn0_sigma)
return s
def hwe_weights(f: F64) -> F64:
"""CN weights under Hardy–Weinberg from haplotype copy-number frequencies f_k (k = 0 … 3 copies):
π_c = Σ_{i+j=c} f_i f_j, CN ≥ 5 pooled into 5 [LIT: HWE; OURS: use as the CN mixture prior]."""
pi = np.zeros(len(CNS))
for i in range(len(f)):
for j in range(len(f)):
pi[min(i + j, CN_MAX)] += f[i] * f[j]
return pi
def _hap_update(r: F64, f: F64) -> F64:
"""M-step for f: split each sample's CN responsibilities over the haplotype pairs (i, j) with i + j = c."""
counts = HAP_ALPHA - 1.0
counts = counts.astype(np.float64).copy()
pi = hwe_weights(f)
for i in range(len(f)):
for j in range(len(f)):
c = min(i + j, CN_MAX)
share = f[i] * f[j] / max(pi[c], 1e-300)
w = float(r[:, c].sum()) * share
counts[i] += w
counts[j] += w
counts = np.maximum(counts, 1e-9)
out: F64 = counts / counts.sum()
return out
def _norm_pdf(x: F64, mu: F64, sd: float | F64) -> F64:
sd_ = np.broadcast_to(np.asarray(sd, dtype=np.float64), mu.shape)[None, :]
return np.exp(-0.5 * ((x[:, None] - mu[None, :]) / sd_) ** 2) / (sd_ * math.sqrt(2 * math.pi))
def _em_region(x: F64, gamma: float, delta: float, iters: int, eps0: float = 0.0) -> tuple[RegionFit, float]:
"""EM for one region; returns the fit and its penalised log-likelihood (HWE weights from haplotype copy
frequencies with a Dirichlet prior, Gaussian prior on γ).
CN 0 has its own shift ε₀ ≤ 0 below the template and its own SD, both shrunk towards the template with a prior
weight of ``CN0_PRIOR_N`` samples (on some probe sets CN 0 is far lower than the compressed template, GSTT1)."""
n = len(x)
f = np.array([0.1, 0.8, 0.07, 0.03])
pi = hwe_weights(f)
sd = max(SIGMA_FLOOR, float(np.std(x)) if n > 1 else 0.05)
sd0 = sd
if eps0 < 0: # a start with a far CN-0 cluster: the other components get the spread of the rest
rest = x[x > delta + gamma * TEMPLATE[0] + eps0 / 2]
sd = max(SIGMA_FLOOR, float(np.std(rest)) if len(rest) > 1 else 0.05)
sd0 = sd
g0, gs = GAMMA_PRIOR
ll = -math.inf
for _ in range(iters):
mu = delta + gamma * TEMPLATE
mu[0] += eps0
sds = np.full(len(CNS), sd)
sds[0] = sd0
dens = pi[None, :] * _norm_pdf(x, mu, sds) + 1e-300
tot = dens.sum(axis=1, keepdims=True)
ll = float(np.log(tot).sum() + ((HAP_ALPHA - 1) * np.log(np.maximum(f, 1e-12))).sum()
- 0.5 * ((gamma - g0) / gs) ** 2) # fmt: skip
r = dens / tot
f = _hap_update(r, f)
pi = hwe_weights(f)
# δ and γ: weighted least squares of x on (1, t_c) over CN ≥ 1, γ shrunk towards its prior
rr = r.copy()
rr[:, 0] = 0.0
w = rr.sum(axis=0)
sw, st, stt = float(w.sum()), float((w * TEMPLATE).sum()), float((w * TEMPLATE**2).sum())
sx, stx = float((rr * x[:, None]).sum()), float((rr * x[:, None] * TEMPLATE[None, :]).sum())
lam = sd**2 / gs**2
det = sw * (stt + lam) - st * st
if det > 0:
delta = (sx * (stt + lam) - st * (stx + lam * g0)) / det
gamma = float(np.clip((sw * (stx + lam * g0) - st * sx) / det, *GAMMA_RANGE))
mu = delta + gamma * TEMPLATE
n1 = float(w.sum())
if n1 > 0:
sd = max(SIGMA_FLOOR, math.sqrt(float((rr * (x[:, None] - mu[None, :]) ** 2).sum() / n1)))
r0 = r[:, 0]
n0 = float(r0.sum())
eps0 = min(0.0, float((r0 * (x - mu[0])).sum()) / (n0 + CN0_PRIOR_N))
v0 = float((r0 * (x - mu[0] - eps0) ** 2).sum())
sd0 = max(sd, math.sqrt((v0 + CN0_PRIOR_N * sd**2) / (n0 + CN0_PRIOR_N)))
return RegionFit(float(delta), float(gamma), sd, pi.tolist(), n, float(eps0), float(sd0)), ll
def fit_region(x: F64, iters: int = 200) -> RegionFit:
"""PCN-009 step 4 for one region: the best of EM runs started with each observed cluster labelled CN 0 … 4, each
with CN 0 on the template or on the lowest values."""
g0 = GAMMA_PRIOR[0]
cands = sorted({round(float(q) - g0 * float(t), 4) for q in np.quantile(x, [0.1, 0.3, 0.5, 0.7, 0.9])
for t in TEMPLATE[:5]}) # fmt: skip
lo = float(np.quantile(x, 0.02))
best: tuple[float, float, float] | None = None
for d0 in cands:
for e0 in sorted({0.0, round(min(0.0, lo - (d0 + g0 * float(TEMPLATE[0]))), 4)}):
_, ll = _em_region(x, g0, d0, 80, e0)
if best is None or ll > best[0] + 1e-9:
best = (ll, d0, e0)
assert best is not None
return _em_region(x, g0, best[1], iters, best[2])[0]
def fit_mixture(values: dict[str, F64], iters: int = 200) -> dict[str, RegionFit]:
"""Per-region fits for every region with ≥ 3 finite values."""
out = {}
for name, x in values.items():
x = x[np.isfinite(x)]
if len(x) >= 3:
out[name] = fit_region(x, iters)
return out
def posterior(x: float, fit: RegionFit) -> F64:
"""P(CN = c | x) for c = 0 … 5, with the training weights (floored) as prior."""
pi = np.maximum(np.asarray(fit.weights), PI_FLOOR)
pi = pi / pi.sum()
d = pi * _norm_pdf(np.array([x]), fit.means(), fit.sds())[0]
s = float(d.sum())
out: F64 = d / s if s > 0 else pi
return out
def baf_loglik(baf: F64, pfb: F64 | None = None, sd: float = 0.05) -> F64:
"""PCN-010: per-CN-state log-likelihood of a region's SNP BAFs: for CN c the BAF is k/c with binomial weights
C(c, k) p^k (1 − p)^(c − k) from the probe's population B-allele frequency p (0.5 when unknown); CN 0 → uniform
[LIT: Wang et al. 2007 (PennCNV BAF emissions); OURS: region-level use]."""
ok = np.isfinite(baf)
b = baf[ok]
p = np.clip(np.nan_to_num(pfb[ok] if pfb is not None else np.full(len(b), 0.5), nan=0.5), 0.01, 0.99)
out = np.zeros(len(CNS))
if len(b) == 0:
return out
for c in CNS:
if c == 0:
out[c] = 0.0 # uniform on [0, 1]
continue
k = np.arange(c + 1)
means = k / c
w = np.array([math.comb(c, int(j)) for j in k])[None, :] * p[:, None] ** k[None, :] * (1 - p[:, None]) ** (
c - k[None, :]) # fmt: skip
dens = np.exp(-0.5 * ((b[:, None] - means[None, :]) / sd) ** 2) / (sd * math.sqrt(2 * math.pi))
out[c] = float(np.log(0.98 * (w * dens).sum(axis=1) + 0.02).sum())
return out
def call_cn(x: float, fit: RegionFit, baf_ll: F64 | None = None) -> tuple[int, int, F64]:
"""PCN-009 steps 5–6: (CN, QUAL, posterior); ``baf_ll`` (PCN-010, experimental ``--bafs-in-cn``) is added to the
log posterior."""
p = posterior(x, fit)
if baf_ll is not None:
lp = np.log(np.maximum(p, 1e-300)) + baf_ll
p = np.exp(lp - lp.max())
p = p / p.sum()
cn = int(np.argmax(p))
pmax = float(p[cn])
qual = 60 if pmax >= 1.0 else int(min(60, max(0, round(-10 * math.log10(max(1e-300, 1 - pmax))))))
return cn, qual, p
@@ -0,0 +1,11 @@
# simurg PGx CN regions v1, GRCh38, from UCSC ncbiRefSeq (PCN-001)
chr1 109687816 109693745 GSTM1 GSTM1
chr4 68537172 68576322 UGT2B17 UGT2B17
chr10 133527362 133539123 CYP2E1 CYP2E1
chr16 28605257 28610044 SULT1A1 SULT1A1
chr19 40844772 40845293 CYP2A6.intron.7 CYP2A6
chr19 40849746 40850947 CYP2A6.exon.1 CYP2A6
chr22 42126498 42126752 CYP2D6.exon.9 CYP2D6
chr22 42129185 42129737 CYP2D6.intron.2 CYP2D6
chr22 42130810 42132810 CYP2D6.p5 CYP2D6
chr22_KI270879v1_alt 270313 278446 GSTT1 GSTT1
+289
View File
@@ -0,0 +1,289 @@
"""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
+129
View File
@@ -0,0 +1,129 @@
"""Diplotype → phenotype lookup — spec PGX-024, PGX-025. Pure functions, no I/O.
[DB: CPIC gene_result_lookup — the diplotype's allele functions (or, for activity-score genes, allele activity
values) as a count key, e.g. {"Normal function": 1, "No function": 1} or {"1.0": 1, "0.25": 1}.] A duplicated allele
``*Nxk`` contributes k × its activity value (CPIC activity-score rule for gene duplications); a whole-gene deletion
(``*5``, ``*0``) has activity 0 and "No function". When a key is absent from the table, activity-score genes fall back
to the total score (the phenotype of the largest tabulated total ≤ the score); anything else is ``Indeterminate`` with
the flag ``phenotype_not_in_guideline`` (PGX-024).
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
@dataclass
class GuidelineTable:
gene: str
by_key: dict[str, tuple[str, str | None]] # canonical key JSON → (phenotype, total activity)
by_total: list[tuple[float, str]] # (total activity, phenotype), sorted
function: dict[str, str | None] # allele → clinical function
activity: dict[str, float | None] # allele → activity value
@property
def activity_based(self) -> bool:
return bool(self.by_total)
@dataclass
class PhenotypeResult:
phenotype: str | None
activity_score: float | None
flags: list[str] = field(default_factory=list)
def _num(v: object) -> float | None:
try:
return float(str(v))
except (TypeError, ValueError):
return None
def build_table(gene: str, rows: list[tuple[str, str | None, str]], alleles: list[tuple[str, str | None, str | None]]
) -> GuidelineTable: # fmt: skip
"""``rows``: (lookup_key JSON, total activity, phenotype); ``alleles``: (allele, function, activity)."""
by_key: dict[str, tuple[str, str | None]] = {}
totals: dict[float, str] = {}
for key, total, pheno in rows:
k = _canon(json.loads(key))
by_key.setdefault(k, (pheno, total))
t = _num(total)
if t is not None:
totals.setdefault(t, pheno)
func: dict[str, str | None] = {a: f.lower() for a, f, _ in alleles if f}
act = {a: _num(v) for a, _, v in alleles}
return GuidelineTable(gene, by_key, sorted(totals.items()), func, act)
def _canon(counts: dict[str, int]) -> str:
"""Case-insensitive lookup key (CPIC mixes "Normal risk …" and "normal risk …", MT-RNR1)."""
merged: dict[str, int] = {}
for k, v in counts.items():
merged[k.lower()] = merged.get(k.lower(), 0) + v
return json.dumps(merged, sort_keys=True)
def _split(allele: str) -> tuple[str, int]:
m = re.fullmatch(r"(.+?)x(\d+|N)", allele)
if not m:
return allele, 1
return m.group(1), (3 if m.group(2) == "N" else int(m.group(2)))
def phenotype(t: GuidelineTable, haplotypes: tuple[str, ...]) -> PhenotypeResult:
"""Phenotype of one diplotype (haplotypes as reported, e.g. ("*2x2", "*4") or ("*1", "*5"))."""
deletion = {"*5", "*0"}
if t.activity_based:
vals: list[float] = []
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}"
counts[s] = counts.get(s, 0) + 1
key = _canon(counts)
total = sum(vals)
hit = t.by_key.get(key)
if hit is not None:
return PhenotypeResult(hit[0], total)
below = [p for tot, p in t.by_total if tot <= total + 1e-9]
if below:
return PhenotypeResult(below[-1], total)
return PhenotypeResult("Indeterminate", total, ["phenotype_not_in_guideline"])
funcs: dict[str, int] = {}
for h in haplotypes:
core, n = _split(h)
f = "no function" if core in deletion else t.function.get(core)
if f is None:
return PhenotypeResult("Indeterminate", None, ["phenotype_not_in_guideline"])
if n > 1:
return PhenotypeResult("Indeterminate", None, ["phenotype_not_in_guideline"]) # no CPIC rule for xN here
funcs[f] = funcs.get(f, 0) + 1
hit = t.by_key.get(_canon(funcs))
if hit is None:
return PhenotypeResult("Indeterminate", None, ["phenotype_not_in_guideline"])
return PhenotypeResult(hit[0], None)
def gene_phenotype(t: GuidelineTable, feasible: list[tuple[str, ...]]) -> tuple[PhenotypeResult, list[str]]:
"""PGX-025: a phenotype only when every feasible solution agrees; otherwise ``Indeterminate`` plus the
per-solution phenotypes."""
res = [phenotype(t, s) for s in feasible]
if not res:
return PhenotypeResult(None, None, ["no_call"]), []
phenos = sorted({r.phenotype or "" for r in res})
if len(phenos) == 1:
acts = {r.activity_score for r in res}
return PhenotypeResult(res[0].phenotype, res[0].activity_score if len(acts) == 1 else None,
sorted({f for r in res for f in r.flags})), [] # fmt: skip
return PhenotypeResult("Indeterminate", None, ["feasible_solutions_disagree"]), [r.phenotype or "" for r in res]
+243
View File
@@ -0,0 +1,243 @@
"""Star-allele / haplotype calling from array genotypes — spec PGX-012 … PGX-020. Pure functions, no I/O.
Evidence model (PGX-012) [OURS]: each defining variant v of a gene is ``called`` (alt dosage g, GenCall score GS,
BAF), ``no_call``, ``masked`` or ``not_covered``; only called variants contribute. With diploid CN 2 the genotype
likelihood is P(g | d) = 1 − ε for the expected alt dosage d and ε/2 otherwise, with ε = 1e-3 for GS ≥ 0.15 and
0.05 below (calibrated: after probe masking, 0 of 9740 GSA v4 PGx calls at DB variants disagreed with 1000G 30x in any
GS bin — errors are probe-specific and handled by masks, not by GS; K-029). When the gene's copy number n ≠ 2
(targeted CN), the BAF likelihood N(b; k/n, σ_b) replaces the
genotype term (k = expected alt copies). Haplotype positions with an IUPAC value (CPIC core definitions) are wildcards:
the best-fitting dosage is used.
Candidates (PGX-013) [DIFF: solver-free]: every unordered pair of defined haplotypes, and for n ≠ 2 the structural
forms of CYP2D6-like genes: n = 1 → one whole-gene deletion haplotype; n = 0 → two; n ≥ 3 → one haplotype duplicated
(``*1x2``, n − 1 copies). Diplotypes that no called variant separates are array-equivalent (PGX-017) and form one
solution, named after the member with the highest prior. Scores (PGX-014): raw_score = softmax(LL); score =
softmax(LL + log π), π = Σ over members of f(h1)·f(h2) (×2 for h1 ≠ h2), f floored at 1e-5.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
import numpy.typing as npt
F64 = npt.NDArray[np.float64]
FREQ_FLOOR = 1e-5
CALLED, NO_CALL, MASKED, NOT_COVERED = 0, 1, 2, 3
STATE_NAMES = {CALLED: "called", NO_CALL: "no_call", MASKED: "masked", NOT_COVERED: "not_on_array"}
@dataclass
class GeneModel:
gene: str
variants: list[tuple[str, int, str, str]] # (chrom, pos, ref, alt), one per defining variant
variant_ids: list[tuple[str, str | None]] # (hgvs, rsid)
haplotypes: list[str]
carries: npt.NDArray[np.int8] # (H, V): 1 carries alt, 0 reference, −1 wildcard
freq: F64 # (H,) population frequency of each haplotype (prior)
deletion_allele: str | None = None # named whole-gene deletion (CYP2D6 *5), or "*0"
reference: str = ""
basis: str = "star_allele" # PGX-002 result basis: star_allele | haplotype_named | variant_based | tagging_variant
activity: dict[str, float] = field(default_factory=dict) # allele → activity value (variant-based ordering)
function: dict[str, str] = field(default_factory=dict)
@dataclass
class Observations:
state: npt.NDArray[np.int8] # (V,)
dosage: npt.NDArray[np.int8] # alt dosage (0 … ploidy), −1 when not called
gs: F64
baf: F64
@dataclass
class Solution:
haplotypes: tuple[str, ...] # representative, e.g. ("*1", "*4") or ("*2x2", "*4")
ll: float
prior: float
members: list[tuple[str, ...]] = field(default_factory=list)
raw_score: float = 0.0
score: float = 0.0
copy_number: int = 2
copies: tuple[int, ...] = () # haplotype index of every gene copy of the representative (SNV model)
@property
def genotype(self) -> str:
return "/".join(self.haplotypes)
ERR_CALLED = 1e-3
ERR_LOW_GS = 0.05
LOW_GS = 0.15
def gs_error(gs: F64) -> F64:
"""PGX-012: per-call error rate from the GenCall score (calibrated, see the module docstring)."""
return np.where(np.nan_to_num(gs, nan=0.0) >= LOW_GS, ERR_CALLED, ERR_LOW_GS)
def _dosage_ll(obs: Observations, expected: npt.NDArray[np.int16], wild: npt.NDArray[np.bool_]) -> F64:
"""(D, V) log-likelihood of each candidate's expected dosage (CN 2 genotype model)."""
eps = gs_error(obs.gs)[None, :]
g = obs.dosage[None, :].astype(np.int16)
hit = (expected == g) | wild
return np.where(hit, np.log(1 - eps), np.log(eps / 2))
def _baf_ll(obs: Observations, frac_lo: F64, frac_hi: F64, sigma: float) -> F64:
"""(D, V) BAF log-likelihood for expected alt fractions (a wildcard spans [lo, hi]: the closest value is used)."""
b = np.nan_to_num(obs.baf, nan=0.5)[None, :]
near = np.clip(b, frac_lo, frac_hi)
return np.log(0.98 * np.exp(-0.5 * ((b - near) / sigma) ** 2) / (sigma * math.sqrt(2 * math.pi)) + 0.02)
def candidates(m: GeneModel, cn: int | None) -> list[tuple[tuple[int, ...], tuple[str, ...]]]:
"""(haplotype index per gene copy, display names) for every candidate diplotype (PGX-013)."""
h = len(m.haplotypes)
out: list[tuple[tuple[int, ...], tuple[str, ...]]] = []
if cn is None or cn == 2:
for i in range(h):
for j in range(i, h):
out.append(((i, j), (m.haplotypes[i], m.haplotypes[j])))
return out
dele = m.deletion_allele or "*0"
if cn == 0:
return [((), (dele, dele))]
if cn == 1:
return [((i,), (m.haplotypes[i], dele)) for i in range(h)]
extra = cn - 2 # copies beyond two: one haplotype duplicated (e.g. CN 3 → *2x2/*4)
for i in range(h):
for j in range(h):
out.append(((i,) * (extra + 1) + (j,), (f"{m.haplotypes[i]}x{extra + 1}", m.haplotypes[j])))
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, 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: 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)
lo = np.zeros((len(cands), carries.shape[1]))
hi = np.zeros_like(lo)
for k, (ix, _) in enumerate(cands):
for i in ix:
c = carries[i]
lo[k] += np.where(c < 0, 0, c)
hi[k] += np.where(c < 0, 1, c)
ncp = np.array([max(1, len(ix)) for ix, _ in cands], dtype=np.float64)[:, None]
if ploidy == 1 or cn is None or cn == 2:
wild = hi != lo
ll_v = _dosage_ll(obs, lo.astype(np.int16), wild)
if wild.any(): # a wildcard may also take the higher dosage
ll_v = np.maximum(ll_v, _dosage_ll(obs, hi.astype(np.int16), wild))
else:
ll_v = _baf_ll(obs, lo / ncp, hi / ncp, baf_sigma)
ll = (ll_v * called[None, :]).sum(axis=1)
f = np.maximum(m.freq, FREQ_FLOOR)
# array-equivalence classes: identical expected dosages at every called variant (PGX-017)
keys: dict[bytes, list[int]] = {}
for k in range(len(cands)):
sig = np.round(np.concatenate([lo[k, called], hi[k, called]]) / ncp[k, 0], 6).tobytes()
keys.setdefault(sig + str(len(cands[k][0])).encode(), []).append(k)
sols = []
for members in keys.values():
pri = []
for k in members:
ix = cands[k][0]
if not ix:
pri.append(1e-6)
continue
p = float(np.prod([f[i] for i in ix]))
if len(set(ix)) > 1:
p *= 2
pri.append(p)
best = members[int(np.argmax(pri))]
sols.append(Solution(cands[best][1], float(ll[best]), float(sum(pri)), [cands[k][1] for k in members],
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])
lp = lls + np.log([s.prior for s in sols])
raw = np.exp(lls - lls.max())
sc = np.exp(lp - lp.max())
raw, sc = raw / raw.sum(), sc / sc.sum()
for s, r, v in zip(sols, raw, sc, strict=True):
s.raw_score, s.score = float(r), float(v)
sols.sort(key=lambda s: (-s.score, -s.raw_score, s.genotype))
top = sols[0].score
feasible = [s for s in sols if s.score >= feasible_ratio * top]
return sols[: max(max_candidates, len(feasible))], feasible
File diff suppressed because it is too large. Load diff
+443
View File
@@ -0,0 +1,443 @@
"""``simurg methyl qc`` / ``simurg methyl convert-manifest`` — methylation control QC (spec MET-001 … MET-018).
Per sample (streamed, worker processes): control table, detection p-values, noob + dye-bias corrected β and M-values.
Per batch: ``methyl_qc_summary.tsv/.xlsx/.html``, ``pcs.tsv.gz``, ``probes.tsv``/``controls.tsv`` and the shared
``controls_raw.tsv``/``controls_qc.tsv`` (so ``simurg qc report`` accepts the folder).
"""
from __future__ import annotations
import datetime as _dt
import gzip
import html
import json
import math
import multiprocessing as mp
import os
import pickle
import tempfile
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import numpy.typing as npt
from simurg.core.atomic import check_outputs_absent
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
from simurg.core.provenance import RunManifest
from simurg.formats.idat import check_pair, read_idat
from simurg.formats.methyl_manifest import MethylManifest, read_methyl_manifest, write_probe_tables
from simurg.pipeline.qc import ControlSample, idat_metadata, write_control_tables, write_tsv
from simurg.qc.controls import ControlBead, canonical_id, compute_control_metrics, metrics_for
from simurg.qc.thresholds import Thresholds, parse_cli_pairs, resolve_thresholds
from simurg.signal import methyl as sig
SUMMARY = "methyl_qc_summary"
CONTROL_TYPES = ("STAINING", "EXTENSION", "HYBRIDIZATION", "TARGET REMOVAL", "NON-POLYMORPHIC", "SPECIFICITY I",
"SPECIFICITY II", "BISULFITE CONVERSION I", "BISULFITE CONVERSION II", "NEGATIVE", "NORM_A",
"NORM_C", "NORM_G", "NORM_T") # fmt: skip
AVG_COLUMNS = ("avg_green_raw", "avg_red_raw", "avg_green_norm", "avg_red_norm")
@dataclass
class MethylQcOptions:
out: Path
manifest: Path
idats: Path | None = None
sheet: Path | None = None
config: Path | None = None
profile: str | None = "auto"
thresholds: list[str] = field(default_factory=list)
bg_offset: float | None = None
bs_conv_red_bg_set: str = "U3-U5"
pval_threshold: float = 0.05
beta_offset: float = 0.0
dye_bias: str = "linear"
min_prop_passing: float | None = None
pca_max_samples_full: int = 100
pca_probes: int = 10_000
seed: int = 42
threads: int = 0
force: bool = False
on_duplicate_idat: str = "error"
argv: list[str] = field(default_factory=list)
@dataclass
class _Job:
sample_id: str
grn: str | None
red: str | None
sheet_values: dict[str, str]
out_dir: str
_W: dict[str, Any] = {}
def _init_from_file(path: str) -> None:
with open(path, "rb") as fh:
_W.update(pickle.load(fh))
def _write_gz_tsv(path: Path, header: str, lines: list[str]) -> None:
tmp = path.with_suffix(path.suffix + ".part")
with gzip.open(tmp, "wt", encoding="utf-8", newline="\n", compresslevel=6) as fh:
fh.write(header + "\n")
fh.write("\n".join(lines))
if lines:
fh.write("\n")
tmp.replace(path)
def _g6(v: float) -> str:
return "NA" if not math.isfinite(v) else f"{v:.6g}"
def _process(job: _Job) -> dict[str, Any]:
man: MethylManifest = _W["man"]
opts: MethylQcOptions = _W["opts"]
beads: list[ControlBead] = _W["beads"]
row: dict[str, Any] = {"sample_id": job.sample_id, "status": "OK", "fail_reason": ""}
if not job.grn or not job.red:
row.update(status="FAILED", fail_reason="W_METH_MISSING_SAMPLE: IDATs absent")
return row
step = "read"
try:
grn, red = read_idat(job.grn), read_idat(job.red)
check_pair(grn, red)
row["meta"] = idat_metadata(grn, red, job.sheet_values)
row["sentrix_id"], row["sentrix_position"] = grn.barcode or red.barcode, grn.position or red.position
row["scan_time"] = red.scan_time_iso or grn.scan_time_iso
step = "signal assembly"
raw = sig.assemble(grn, red, man)
g, r = raw.ctrl_green, raw.ctrl_red
qc = compute_control_metrics(beads, g, r, "methylation", _W["bg_offset"], opts.bs_conv_red_bg_set)
row["control"] = ControlSample(job.sample_id, row["meta"], g, r, qc)
step = "detection"
pval = sig.detection_p(raw)
step = "noob"
corr, _, _ = sig.noob(raw)
step = "dye bias"
corr = sig.dye_bias_nonlinear(corr) if opts.dye_bias == "nonlinear" else sig.dye_bias_linear(corr)
step = "beta"
beta = sig.beta_values(corr, opts.beta_offset)
mval = sig.m_values(beta)
ana = _W["analytical"]
row["n_probes"] = int(ana.sum())
row["n_pass_detection"] = int((pval[ana] < opts.pval_threshold).sum())
row["prop_probes_passing"] = row["n_pass_detection"] / row["n_probes"] if row["n_probes"] else math.nan
row["avg_green_raw"], row["avg_red_raw"] = sig.average_signals(raw, ana)
row["avg_green_norm"], row["avg_red_norm"] = sig.average_signals(corr, ana)
row["pca"] = beta[_W["pca_idx"]].astype(np.float32)
step = "write"
d = Path(job.out_dir)
d.mkdir(parents=True, exist_ok=True)
_write_gz_tsv(
d / f"{job.sample_id}_ctrl.tsv.gz",
"address\ttype\tcolor_channel\tname\tprobe_id\tgreen\tred",
[
f"{c.address}\t{c.type}\t{c.color_channel}\t{c.name}\t{c.probe_id}\t{_g6(gv)}\t{_g6(rv)}"
for c, gv, rv in zip(man.controls, g.tolist(), r.tolist(), strict=True)
],
)
_write_gz_tsv(
d / f"{job.sample_id}_cgs.tsv.gz",
"probe_id\tbeta\tm_value\tdetection_p",
[
f"{pid}\t{_g6(b)}\t{_g6(mv)}\t{_g6(pv)}"
for pid, b, mv, pv in zip(man.probe_ids, beta.tolist(), mval.tolist(), pval.tolist(), strict=True)
],
)
except Exception as e: # MET-010: this sample fails, the others continue
code = e.code if isinstance(e, SimurgError) else "E_METH_PREP_FAILED"
row.update(status="FAILED", fail_reason=f"{code}: Prep failed ({step}): {e}")
row.pop("control", None)
return row
def _pca(mat: npt.NDArray[np.float32]) -> npt.NDArray[np.float64] | None:
"""First two principal components (samples × 2) of a samples × probes matrix; None when not computable."""
ok = np.isfinite(mat).all(axis=0)
x = mat[:, ok].astype(np.float64)
if x.shape[0] < 2 or x.shape[1] < 2:
return None
x -= x.mean(axis=0)
gram = x @ x.T
w, v = np.linalg.eigh(gram)
order = np.argsort(w)[::-1][:2]
pcs = v[:, order] * np.sqrt(np.maximum(w[order], 0))
for j in range(pcs.shape[1]): # deterministic sign: largest |loading| positive
k = int(np.argmax(np.abs(pcs[:, j])))
if pcs[k, j] < 0:
pcs[:, j] = -pcs[:, j]
if pcs.shape[1] < 2:
pcs = np.column_stack([pcs, np.zeros(len(pcs))])
return pcs
def _thresholds(opts: MethylQcOptions, man: MethylManifest) -> Thresholds:
cli: dict[str, Any] = parse_cli_pairs(opts.thresholds)
if opts.bg_offset is not None:
cli["bgOffset"] = opts.bg_offset
return resolve_thresholds(
config=opts.config,
profile=opts.profile,
cli=cli,
array_type="methylation",
manifest_name=man.heading.get("Descriptor File Name", Path(man.path).name),
)
def run_methyl_qc(opts: MethylQcOptions, rep: Reporter) -> int:
from simurg.pipeline.inputs import check_paths, resolve_sample_set, sheet_values
rm = RunManifest(command="methyl qc", argv=opts.argv)
rep.run_id = rm.run_id
check_paths([(opts.manifest, "methylation manifest"), (opts.idats, "IDAT folder"), (opts.sheet, "sheet")])
try:
sset = resolve_sample_set(opts.idats, opts.sheet, opts.on_duplicate_idat, rep, rm)
except SimurgError as e:
if e.code == "E_NO_INPUT":
raise SimurgError("E_METH_NO_IDATS", f"No IDATs found ({e.message})") from None
raise
outputs = [opts.out / f"{SUMMARY}.{x}" for x in ("tsv", "xlsx", "html")] + [opts.out / "pcs.tsv.gz"]
check_outputs_absent(outputs, force=opts.force)
rep.info(f"reading methylation manifest {opts.manifest}")
man = read_methyl_manifest(opts.manifest)
opts.out.mkdir(parents=True, exist_ok=True)
for p in write_probe_tables(man, opts.out):
rm.add_output(p, "simurg.methyl_manifest/1")
th = _thresholds(opts, man)
for rec in th.as_records():
rm.set_option(f"qc.{rec['key']}", rec["value"], rec["source"])
beads = [ControlBead(c.type, c.color_channel, c.name, c.address, canonical_id(c.name)) for c in man.controls]
types = {c.type.upper() for c in man.controls}
if miss := [t for t in CONTROL_TYPES if t not in types]:
rep.warn("W_METH_MISSING_CONTROL_TYPES", f"manifest lacks control type(s): {', '.join(miss)}")
analytical = man.analytical()
n = len(sset.samples)
cand = np.flatnonzero(analytical)
if n > opts.pca_max_samples_full and len(cand) > opts.pca_probes:
cand = np.sort(np.random.default_rng(opts.seed).choice(cand, opts.pca_probes, replace=False))
jobs = []
for s in sset.samples:
grn = str(s.pair.grn) if s.pair is not None and s.pair.grn is not None else None
red = str(s.pair.red) if s.pair is not None and s.pair.red is not None else None
if not (grn and red):
rep.warn("W_METH_MISSING_SAMPLE", f"Missing sample: {s.sample_id}", sample_id=s.sample_id)
jobs.append(_Job(s.sample_id, grn, red, sheet_values(s, sset.sheet), str(opts.out / "samples" / s.sample_id)))
state = {"man": man, "opts": opts, "beads": beads, "bg_offset": th.bg_offset, "analytical": analytical,
"pca_idx": cand} # fmt: skip
workers = max(1, min(opts.threads or (os.cpu_count() or 1), len(jobs) or 1))
rep.info(f"methylation QC of {len(jobs)} sample(s) with {workers} worker(s)")
if workers == 1:
_W.update(state)
try:
rows = [_process(j) for j in jobs]
finally:
_W.clear()
else:
fd, path = tempfile.mkstemp(prefix=".simurg-workers-", suffix=".pkl", dir=opts.out)
try:
with os.fdopen(fd, "wb") as fh:
pickle.dump(state, fh, protocol=5)
ctx = mp.get_context("spawn")
with ProcessPoolExecutor(workers, mp_context=ctx, initializer=_init_from_file, initargs=(path,)) as ex:
rows = list(ex.map(_process, jobs))
finally:
Path(path).unlink(missing_ok=True)
metrics = metrics_for("methylation")
col_key = {m.column: m.key for m in metrics}
missing_names: dict[str, list[str]] = {}
for r in rows:
if r["status"] != "OK":
rep.error(
r["fail_reason"].split(":", 1)[0], f"{r['sample_id']}: {r['fail_reason']}", sample_id=r["sample_id"]
)
continue
q = r["control"].qc
missing_names.update(q.missing)
if not r["scan_time"]:
rep.warn("W_IDAT_NO_SCAN_TIME", f"{r['sample_id']}: No scan time available", sample_id=r["sample_id"])
fails = [m.column for m in metrics if m.column not in q.missing and th.get(m.key).fails(q.values[m.column])]
if opts.min_prop_passing is not None and not r["prop_probes_passing"] >= opts.min_prop_passing:
fails.append("prop_probes_passing")
r["fail_codes"] = ";".join(fails)
r["na_metrics"] = ";".join(q.na_metrics)
r["pass_qc"] = 0 if fails else 1
r.update({m.column: q.values[m.column] for m in metrics})
if missing_names:
ids = sorted({c for v in missing_names.values() for c in v})
rep.warn("W_METH_MISSING_CONTROL_NAMES", f"manifest lacks control(s) {', '.join(ids)}; "
f"not evaluable: {', '.join(sorted(missing_names))}") # fmt: skip
meta_cols: list[str] = []
for j in jobs:
for k in j.sheet_values:
if k not in meta_cols:
meta_cols.append(k)
cols = ["sentrix_id", "sentrix_position", "sample_id", *meta_cols, *(m.column for m in metrics), *AVG_COLUMNS,
"scan_time", "n_probes", "n_pass_detection", "prop_probes_passing", "pass_qc", "fail_codes",
"na_metrics", "status", "fail_reason"] # fmt: skip
out_rows = []
for j, r in zip(jobs, rows, strict=True):
out_rows.append({**j.sheet_values, **{c: r.get(c, "") for c in cols if c not in j.sheet_values}})
summary_tsv = opts.out / f"{SUMMARY}.tsv"
write_tsv(summary_tsv, cols, out_rows)
rm.add_output(summary_tsv, "simurg.methyl_qc_summary/1")
_write_xlsx(opts.out / f"{SUMMARY}.xlsx", cols, out_rows, th, col_key)
ok_rows = [r for r in rows if r["status"] == "OK"]
samples = [r["control"] for r in ok_rows]
if samples:
for p in write_control_tables(opts.out, samples, beads, th.bg_offset, rep, array_type="methylation"):
rm.add_output(p, "simurg.controls/1")
pcs = _pca(np.vstack([r["pca"] for r in ok_rows])) if len(ok_rows) >= 2 else None
group_col = next((k for k in meta_cols if k.casefold() == "sample_group"), None)
groups = {j.sample_id: (j.sheet_values.get(group_col, "") if group_col else "") for j in jobs}
pc_rows = []
if pcs is None:
rep.warn("W_METH_NO_FEATURES", "No features, skipping PCA plot")
else:
for r, (p1, p2) in zip(ok_rows, pcs.tolist(), strict=True):
pc_rows.append({"sample_id": r["sample_id"], "pc1": p1, "pc2": p2,
"sample_group": groups[r["sample_id"]] or "NA"}) # fmt: skip
with gzip.open(opts.out / "pcs.tsv.gz", "wt", encoding="utf-8", newline="\n") as fh:
fh.write("sample_id\tpc1\tpc2\tsample_group\n")
for pr in pc_rows:
fh.write(f"{pr['sample_id']}\t{pr['pc1']:.6g}\t{pr['pc2']:.6g}\t{pr['sample_group']}\n")
_write_html(opts.out / f"{SUMMARY}.html", out_rows, pc_rows, th, metrics, man, len(cand))
n_ok = len(ok_rows)
rm.summary = {"samples": len(rows), "prepared": n_ok, "pass_qc": sum(r.get("pass_qc") == 1 for r in ok_rows)}
rm.status = "completed"
rm.write(opts.out / "run_manifest.json")
rep.info(f"done: {n_ok}/{len(rows)} samples prepared, {rm.summary['pass_qc']} pass QC; summary {summary_tsv}")
if n_ok == len(rows):
return ExitCode.OK
return ExitCode.PARTIAL if n_ok else ExitCode.ALL_FAILED
def _write_xlsx(
path: Path, cols: list[str], rows: list[dict[str, Any]], th: Thresholds, col_key: dict[str, str]
) -> None:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
bad = PatternFill("solid", fgColor="F5C28E")
wb = Workbook()
ws = wb.active
ws.title = "Samples"
ws.append(cols)
for c in ws[1]:
c.font = Font(bold=True)
for r in rows:
ws.append([_xl(r.get(c)) for c in cols])
cur = ws.max_row
for j, c in enumerate(cols, start=1):
v = r.get(c)
na = c in str(r.get("na_metrics", "")).split(";")
metric_fails = c in col_key and isinstance(v, float) and not na and th.get(col_key[c]).fails(v)
if metric_fails or (c == "pass_qc" and v == 0):
ws.cell(cur, j).fill = bad
ws.freeze_panes = "D2"
ws.auto_filter.ref = ws.dimensions
t2 = wb.create_sheet("Thresholds")
t2.append(["key", "threshold", "direction", "source"])
for k in col_key.values():
t = th.get(k)
t2.append([k, t.value, t.direction, t.source])
tmp = path.with_suffix(".xlsx.part")
wb.save(tmp)
tmp.replace(path)
def _xl(v: Any) -> Any:
if isinstance(v, float) and not math.isfinite(v):
return "NaN" if math.isnan(v) else ("Inf" if v > 0 else "-Inf")
return "" if v is None else v
def _write_html(
path: Path,
rows: list[dict[str, Any]],
pcs: list[dict[str, Any]],
th: Thresholds,
metrics: list[Any],
man: MethylManifest,
n_pca_probes: int,
) -> None:
"""MET-013: self-contained page — summary table, detection histogram, PCA of β."""
from simurg.pipeline.qc_report import _plotly
ok = [r for r in rows if r.get("status") == "OK"]
fin = [r for r in ok if math.isfinite(r["prop_probes_passing"])]
hist = {
"pass": [r["prop_probes_passing"] for r in fin if r.get("pass_qc") == 1],
"fail": [r["prop_probes_passing"] for r in fin if r.get("pass_qc") != 1],
}
data = {
"hist": hist,
"pcs": pcs,
"n_pca_probes": n_pca_probes,
}
head = ["sample_id", "pass_qc", "prop_probes_passing", "fail_codes", "na_metrics", "scan_time"]
trs = "".join("<tr>" + "".join(f"<td>{html.escape(_cell(r.get(c)))}</td>" for c in head) + "</tr>" for r in rows)
th_rows = "".join(
f"<tr><td>{m.column}</td><td>{m.key}</td><td>{_cell(th.get(m.key).value)}</td></tr>" for m in metrics
)
generated = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d %H:%M UTC")
page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Methylation QC</title>
<style>body{{font:14px system-ui,sans-serif;margin:16px;color:#222;background:#fff}}table{{border-collapse:collapse}}
td,th{{border:1px solid #ccc;padding:3px 6px;text-align:left}}.plots{{display:flex;flex-wrap:wrap;gap:16px}}
.plot{{width:min(560px,100%);height:380px}}.note{{color:#666}}</style>
<script>{_plotly()}</script></head><body>
<h1>Methylation control QC</h1>
<p class="note">{len(rows)} samples · manifest {html.escape(Path(man.path).name)}
· profile {html.escape(th.profile or "none")} · generated {generated} · SimurgArray</p>
<div class="plots"><div id="hist" class="plot"></div><div id="pca" class="plot"></div></div>
<h2>Samples</h2><table><tr>{"".join(f"<th>{c}</th>" for c in head)}</tr>{trs}</table>
<h2>Thresholds</h2><table><tr><th>metric</th><th>key</th><th>threshold</th></tr>{th_rows}</table>
<script>const D={json.dumps(data, allow_nan=False, default=_json_num)};
Plotly.newPlot("hist",[{{x:D.hist.pass,type:"histogram",name:"pass",marker:{{color:"#4c78a8"}}}},
{{x:D.hist.fail,type:"histogram",name:"fail",marker:{{color:"#e45756"}}}}],
{{title:"Probes passing detection",barmode:"overlay",xaxis:{{title:"proportion"}},yaxis:{{title:"samples"}}}});
if(D.pcs.length){{const g=[...new Set(D.pcs.map(p=>p.sample_group))];
Plotly.newPlot("pca",g.map(k=>({{x:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc1),
y:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc2),text:D.pcs.filter(p=>p.sample_group===k).map(p=>p.sample_id),
mode:"markers",type:"scatter",name:k}})),{{title:"PCA of beta ("+D.n_pca_probes+" candidate probes)",
xaxis:{{title:"PC1"}},yaxis:{{title:"PC2"}}}});}}
else{{document.getElementById("pca").textContent="No features, PCA skipped";}}
</script></body></html>
"""
tmp = path.with_suffix(".html.part")
tmp.write_text(page, encoding="utf-8")
tmp.replace(path)
def _cell(v: Any) -> str:
if v is None:
return ""
if isinstance(v, float):
return "NaN" if math.isnan(v) else f"{v:.4g}"
return str(v)
def _json_num(v: Any) -> Any:
if isinstance(v, np.floating | np.integer):
return v.item()
raise TypeError(type(v))
def run_convert_manifest(manifest: Path, out: Path, force: bool, rep: Reporter) -> int:
"""``simurg methyl convert-manifest`` (MET-003)."""
check_outputs_absent([out / "probes.tsv", out / "controls.tsv"], force=force)
man = read_methyl_manifest(manifest)
probes, ctl = write_probe_tables(man, out)
rep.info(f"{man.num_probes} probes -> {probes}; {len(man.controls)} controls -> {ctl}")
return ExitCode.OK
+592
View File
@@ -0,0 +1,592 @@
"""``simurg pgx copy-number train|call`` — targeted PGx copy number (spec PCN-001 … PCN-017, CLI-031/032, IN-050).
I/O and orchestration only; the numerics live in ``simurg.pgx.cn``.
Deviations (K-028): the per-probe reference is the training cohort's median, stored in the model (not the calling
batch's median, which is not CN 2 in regions with common deletions such as GSTM1); no GC correction step (the region
median over many probes plus the sample scale already removes most of it); GSTT1 needs alt-contig probe coordinates,
which the GSA v4 PGx GRCh38 manifest carries (160 probes on `chr22_KI270879v1_alt`); manifests without them give
`NoCall` for GSTT1.
"""
from __future__ import annotations
import hashlib
import io
import json
import math
import zipfile
from dataclasses import dataclass, field
from datetime import UTC, datetime
from importlib import resources
from pathlib import Path
from typing import Any
import numpy as np
import numpy.typing as npt
import pyarrow as pa
import pyarrow.parquet as pq
from simurg import __version__
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
from simurg.formats.bpm import read_bpm
from simurg.pgx.cn import CN_MAX, Region, RegionFit, baf_loglik, call_cn, fit_mixture, parse_regions
MODEL_SCHEMA = 1
REGIONS_FILE = "pgx_cn_regions.GRCh38.v1.bed"
N_SCALE_PROBES = 20_000
MIN_REGION_PROBES = 10 # PCN-008
MIN_FINITE_FRAC = 0.5 # PCN-002: fewer finite region values → NoCall [OURS]
F32 = npt.NDArray[np.float32]
def default_regions() -> tuple[str, str]:
"""(file name, text) of the packaged region BED (PCN-001)."""
return REGIONS_FILE, resources.files("simurg.pgx.data").joinpath(REGIONS_FILE).read_text(encoding="utf-8")
def _core(chrom: str) -> str:
c = chrom[3:] if chrom.lower().startswith("chr") else chrom
return "X" if c == "XY" else c
@dataclass
class PgxCnModel:
header: dict[str, Any]
regions: list[Region]
regions_text: str
probe_idx: npt.NDArray[np.int64] # manifest indices of all probes used (region probes, then scale probes)
probe_region: list[str] # region name, "" for scale probes
probe_ref: F32 # training reference (median sample-centred LRR)
fits: dict[str, RegionFit] = field(default_factory=dict)
probe_pfb: F32 | None = None # training B-allele frequency per probe (NaN: no called genotypes), PCN-010
def region_sel(self, name: str) -> npt.NDArray[np.int64]:
return np.flatnonzero(np.asarray(self.probe_region) == name)
def region_probes(
regions: list[Region], chroms: list[str], pos: npt.NDArray[np.int64], masked: set[int]
) -> dict[str, npt.NDArray[np.int64]]:
"""Manifest indices inside each region (1-based position in (start0, end]), mask removed (PCN-008)."""
core = np.array([_core(c) for c in chroms])
out = {}
for r in regions:
sel = np.flatnonzero((core == _core(r.chrom)) & (pos > r.start0) & (pos <= r.end))
out[r.name] = np.array([i for i in sel.tolist() if i not in masked], dtype=np.int64)
return out
def scale_probes(
chroms: list[str], pos: npt.NDArray[np.int64], exclude: npt.NDArray[np.int64], n: int = N_SCALE_PROBES
) -> npt.NDArray[np.int64]:
"""Seeded random autosomal non-target probes for the per-sample scale (PCN-009 step 2)."""
core = np.array([_core(c) for c in chroms])
ok = np.isin(core, [str(i) for i in range(1, 23)]) & (pos > 0)
ok[exclude] = False
cand = np.flatnonzero(ok)
rng = np.random.default_rng(20260929)
return np.sort(rng.choice(cand, min(n, len(cand)), replace=False)).astype(np.int64)
def _centred(lrr: npt.NDArray[np.float32], auto_idx: npt.NDArray[np.int64]) -> npt.NDArray[np.float64]:
x = lrr.astype(np.float64)
x[~np.isfinite(x)] = np.nan
med = float(np.nanmedian(x[auto_idx])) if np.isfinite(x[auto_idx]).any() else 0.0
return x - med
def region_values(d: npt.NDArray[np.float64], model: PgxCnModel) -> dict[str, tuple[float, int]]:
"""Per region: (x, n finite probes). ``d`` = centred LRR at ``model.probe_idx`` minus the reference."""
scale_sel = model.region_sel("")
sc = float(np.nanmedian(d[scale_sel])) if np.isfinite(d[scale_sel]).any() else 0.0
out = {}
for r in model.regions:
sel = model.region_sel(r.name)
v = d[sel]
nf = int(np.isfinite(v).sum())
out[r.name] = (float(np.nanmedian(v)) - sc if nf else math.nan, nf)
return out
# ------------------------------------------------------------------------------------------------ model file
def write_model(path: Path, m: PgxCnModel) -> None:
cols = {"index": m.probe_idx, "region": m.probe_region, "ref": m.probe_ref, "name": m.header.pop("_names")}
if m.probe_pfb is not None:
cols["pfb"] = m.probe_pfb
table = pa.table(cols)
buf = io.BytesIO()
pq.write_table(table, buf, compression="zstd")
mixture = {name: {"delta": f.delta, "gamma": f.gamma, "sigma": f.sigma, "weights": f.weights, "n": f.n,
"cn0_shift": f.cn0_shift, "cn0_sigma": f.cn0_sigma} for name, f in m.fits.items()} # fmt: skip
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".part")
with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_STORED) as z:
z.writestr("header.json", json.dumps(m.header, indent=1))
z.writestr("probes.parquet", buf.getvalue())
z.writestr("regions.bed", m.regions_text)
z.writestr("mixture.json", json.dumps(mixture, indent=1))
tmp.replace(path)
def read_model(path: Path) -> PgxCnModel:
"""IN-050."""
with zipfile.ZipFile(path) as z:
header = json.loads(z.read("header.json"))
table = pq.read_table(io.BytesIO(z.read("probes.parquet")))
regions_text = z.read("regions.bed").decode()
mixture = json.loads(z.read("mixture.json"))
if int(header.get("schema_version", 0)) < MODEL_SCHEMA:
raise SimurgError("E_MODEL_VERSION", f"{path}: PGx CN model schema {header.get('schema_version')} is too old",
hint="retrain it with simurg pgx copy-number train") # fmt: skip
fits = {
k: RegionFit(
v["delta"],
v["gamma"],
v["sigma"],
list(v["weights"]),
int(v["n"]),
v.get("cn0_shift", 0.0),
v.get("cn0_sigma", 0.0),
)
for k, v in mixture.items()
}
return PgxCnModel(header, parse_regions(regions_text), regions_text,
table.column("index").to_numpy().astype(np.int64), table.column("region").to_pylist(),
table.column("ref").to_numpy().astype(np.float32), fits,
table.column("pfb").to_numpy().astype(np.float32)
if "pfb" in table.column_names else None) # fmt: skip
# ------------------------------------------------------------------------------------------------ train
@dataclass
class PgxTrainOptions:
gtcs: Path
bpm: Path
fasta: Path
out: Path
mask: Path | None = None
regions: Path | None = None
summary: Path | None = None
platform: str = "auto"
csv: Path | None = None # CSV manifest: source of the Assay Format heading (CLI-032)
min_samples: int = 96
min_passing: int = 90
max_samples: int = 3000
lrr_sd_max: float = 0.20
call_rate_min: float = 0.99
assay_control_min: float = 1.0
force: bool = False
argv: list[str] = field(default_factory=list)
def read_mask_tsv(path: Path | None) -> tuple[set[str], str]:
"""IN-052: ``probe_name<TAB>reason``; returns (names, sha256)."""
if path is None:
return set(), ""
data = path.read_bytes()
names = set()
for ln in data.decode("utf-8").splitlines():
if ln.strip() and not ln.startswith("#"):
names.add(ln.split("\t")[0].strip())
return names, hashlib.sha256(data).hexdigest()
def _summary(path: Path | None) -> dict[str, dict[str, str]]:
if path is None or not path.exists():
return {}
lines = path.read_text(encoding="utf-8").splitlines()
head = lines[0].split("\t")
return {r[0]: dict(zip(head, r, strict=False)) for r in (ln.split("\t") for ln in lines[1:] if ln)}
def _assay_control(row: dict[str, str]) -> float:
for k, v in row.items():
if k.endswith("_norm_r") and k.startswith("TGA_Ctrl"):
try:
return float(v)
except ValueError:
return math.nan
return math.nan
def _probe_set_sha(names: list[str], chroms: list[str], pos: npt.NDArray[np.int64]) -> str:
text = "\n".join(f"{n}\t{c}\t{p}" for n, c, p in zip(names, chroms, pos.tolist(), strict=True))
return hashlib.sha256(text.encode()).hexdigest()
def run_train(o: PgxTrainOptions, rep: Reporter) -> int:
"""``simurg pgx copy-number train`` (CLI-032, PCN-005)."""
from simurg.core.atomic import check_outputs_absent
from simurg.formats.discovery import discover_gtcs
from simurg.formats.fasta import Genome
from simurg.formats.gtc import read_gtc
from simurg.pipeline.qc import write_tsv
man = read_bpm(o.bpm)
csv_fmt = o.platform
if o.platform == "auto":
from simurg.formats.csv_manifest import assay_format, read_csv_manifest
csv_fmt = assay_format(read_csv_manifest(o.csv)) if o.csv else ""
if csv_fmt not in ("LCG", "EX"):
raise SimurgError(
"E_PGX_PLATFORM_UNKNOWN",
"cannot tell the platform (LCG/EX)" + (f": Assay Format {csv_fmt!r}" if csv_fmt else ": no --csv"),
hint="give --csv or --platform LCG|EX",
)
out_model = o.out / f"{o.bpm.stem}.simurg-pgxcn"
check_outputs_absent([out_model, o.out / "train_exclusions.tsv"], force=o.force)
genome = Genome(o.fasta)
if genome.build not in ("GRCh38", "unknown", ""):
raise SimurgError("E_BUILD_MISMATCH", f"PGx CN supports GRCh38 only (FASTA is {genome.build})") # PCN-015
reg_name, reg_text = (o.regions.name, o.regions.read_text()) if o.regions else default_regions()
regions = parse_regions(reg_text)
mask_path = o.mask or (
o.bpm.with_name(o.bpm.stem + ".mask.tsv") if (o.bpm.with_name(o.bpm.stem + ".mask.tsv")).exists() else None
)
mask_names, mask_sha = read_mask_tsv(mask_path)
masked = {i for i, n in enumerate(man.names) if n in mask_names}
pos = man.map_infos.astype(np.int64)
rp = region_probes(regions, list(man.chroms), pos, masked)
for r in regions:
if len(rp[r.name]) < MIN_REGION_PROBES:
rep.warn("W_PGX_REGION_FEW_PROBES", f"region {r.name}: {len(rp[r.name])} unmasked probe(s) on "
f"{man.manifest_name}; it will be NoCall") # fmt: skip
reg_idx = np.concatenate([rp[r.name] for r in regions]) if regions else np.zeros(0, np.int64)
sc_idx = scale_probes(list(man.chroms), pos, reg_idx)
probe_idx = np.concatenate([reg_idx, sc_idx])
probe_region = [r.name for r in regions for _ in range(len(rp[r.name]))] + [""] * len(sc_idx)
gtcs = discover_gtcs(o.gtcs)
keys = sorted(gtcs)
if len(keys) > o.max_samples:
rng = np.random.default_rng(42)
keys = sorted(rng.choice(keys, o.max_samples, replace=False).tolist())
rep.info(f"training on a seeded random subset of {o.max_samples} of {len(gtcs)} samples (PCN-005)")
summ = _summary(o.summary or (o.gtcs / "sample_summary.tsv"))
rows = np.full((len(keys), len(probe_idx)), np.nan, dtype=np.float32)
alt_count = np.zeros(len(probe_idx))
n_called = np.zeros(len(probe_idx))
exclusions: list[dict[str, str]] = []
passing = np.zeros(len(keys), dtype=np.bool_)
cluster_files = set()
for i, k in enumerate(keys):
g = read_gtc(gtcs[k])
if len(g.logr_ratios) != man.num_loci:
raise SimurgError("E_MODEL_MANIFEST_MISMATCH", f"{gtcs[k]} has {len(g.logr_ratios)} loci, the manifest "
f"{man.num_loci}") # fmt: skip
cluster_files.add(g.cluster_file)
rows[i] = _centred(g.logr_ratios, sc_idx)[probe_idx]
gt = np.asarray(g.genotypes)[probe_idx]
n_called += gt > 0
alt_count += np.where(gt == 2, 1, np.where(gt == 3, 2, 0))
reasons = []
if not g.logr_dev <= o.lrr_sd_max:
reasons.append(f"logr_dev {g.logr_dev:.3f} > {o.lrr_sd_max}")
if not g.call_rate >= o.call_rate_min:
reasons.append(f"call_rate {g.call_rate:.4f} < {o.call_rate_min}")
ac = _assay_control(summ.get(k, {}))
if math.isfinite(ac) and ac < o.assay_control_min:
reasons.append(f"assay_control_norm_r {ac:.3f} < {o.assay_control_min}")
if reasons:
exclusions.append({"sample_id": k, "reason": "; ".join(reasons)})
else:
passing[i] = True
n_pass = int(passing.sum())
if len(keys) < o.min_samples or n_pass < o.min_passing:
raise SimurgError("E_PGX_TRAIN_TOO_SMALL", f"{n_pass} of {len(keys)} sample(s) passed QC; training requires "
f"at least {o.min_samples} samples of which {o.min_passing} pass",
hint="lower --min-samples/--min-passing only for research use") # fmt: skip
ref = np.nanmedian(rows[passing], axis=0)
d = rows - ref
sc_sel = np.flatnonzero(np.asarray(probe_region) == "")
sc = np.nanmedian(d[:, sc_sel], axis=1)
def robust_z(v: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
med = float(np.nanmedian(v[passing]))
mad = float(np.nanmedian(np.abs(v[passing] - med))) * 1.4826 or 1e-9
out: npt.NDArray[np.float64] = np.abs(v - med) / mad
return out
# PCN-009 / EDGE-026: outlier samples out of the reference — genome-wide level or genome-wide noise
noise = 1.4826 * np.nanmedian(np.abs(d[:, sc_sel] - sc[:, None]), axis=1)
z, zn = robust_z(sc), robust_z(noise)
use = passing & (z <= 5) & (zn <= 5)
for i in np.flatnonzero(passing & ~use).tolist():
exclusions.append({"sample_id": keys[i], "reason": f"genome-wide NR outlier (level |z| = {z[i]:.1f}, "
f"noise |z| = {zn[i]:.1f}; limit 5)"}) # fmt: skip
if (~use & passing).any():
ref = np.nanmedian(rows[use], axis=0)
d = rows - ref
with np.errstate(invalid="ignore", divide="ignore"):
pfb = np.where(n_called > 0, alt_count / (2 * n_called), np.nan).astype(np.float32)
model = PgxCnModel({}, regions, reg_text, probe_idx, probe_region, ref.astype(np.float32), probe_pfb=pfb)
values = {}
for r in regions:
if len(rp[r.name]) < MIN_REGION_PROBES:
continue
values[r.name] = np.array([region_values(d[i], model)[r.name][0] for i in np.flatnonzero(use)])
model.fits = fit_mixture(values)
model.header = {
"schema": "simurg.pgx_cn_model", "schema_version": MODEL_SCHEMA, "software_version": __version__,
"manifest_name": man.manifest_name, "manifest_revision": getattr(man, "version", None),
"probe_set_sha256": _probe_set_sha(man.names, list(man.chroms), pos),
"n_probes": man.num_loci,
"cluster_file_name": sorted(cluster_files)[0] if len(cluster_files) == 1 else sorted(cluster_files),
"genome_build": "GRCh38", "fasta_contigs_sha256": genome.sha256, "platform": csv_fmt,
"mask_sha256": mask_sha, "regions_bed_version": reg_name, "n_training_samples": int(use.sum()),
"training_exclusions": len(exclusions), "created": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
"regions_few_probes": [r.name for r in regions if len(rp[r.name]) < MIN_REGION_PROBES],
"model": "mu_c = delta_r + gamma_r * log2((c + 1) / 3), c = 0..5 (simurg.pgx.cn)",
"_names": [man.names[i] for i in probe_idx.tolist()],
} # fmt: skip
o.out.mkdir(parents=True, exist_ok=True)
write_model(out_model, model)
write_tsv(o.out / "train_exclusions.tsv", ["sample_id", "reason"], exclusions)
for name, f in model.fits.items():
rep.info(f"{name}: CN2 at {f.delta:+.3f}, gamma {f.gamma:.3f}, sd {f.sigma:.3f}, "
f"weights {' '.join(f'{w:.2f}' for w in f.weights)}") # fmt: skip
rep.info(f"PGx CN model from {int(use.sum())} sample(s) written to {out_model}")
return ExitCode.OK
# ------------------------------------------------------------------------------------------------ call
@dataclass
class PgxCallOptions:
gtcs: Path
model: Path
fasta: Path
out: Path
min_batch: int = 24
min_passing: int = 22
recommended_passing: int = 96
lrr_sd_max: float = 0.20
cluster_mismatch: str = "error"
bafs_in_cn: bool = False # PCN-010 experimental
bgzip: bool = True
force: bool = False
argv: list[str] = field(default_factory=list)
def _vcf_header(sample: str, contigs: list[tuple[str, int]], meta: dict[str, str]) -> str:
h = ["##fileformat=VCFv4.3", f"##source=SimurgArray {__version__} pgx-cn", f"##reference=file://{meta['fasta']}",
"##simurg_genome_build=GRCh38", f"##simurg_manifest={meta['manifest']}",
f"##simurg_pgx_cn_model={meta['model']}", f"##simurg_batch={meta['batch']}",
f"##OverallPloidy={meta['ploidy']}", "##GCCorrect=false"] # fmt: skip
h += [f"##contig=<ID={c},length={n}>" for c, n in contigs]
h += ['##ALT=<ID=CNV,Description="Copy number variable region (targeted genotype)">',
'##FILTER=<ID=PASS,Description="All filters passed">',
'##FILTER=<ID=Q7,Description="Quality below 7">',
'##FILTER=<ID=SampleQuality,Description="Sample LRR SD above 0.2">',
'##FILTER=<ID=NoCall,Description="Region could not be assessed">',
'##INFO=<ID=END,Number=1,Type=Integer,Description="Last base of the region (1-based, inclusive)">',
'##INFO=<ID=SVTYPE,Number=1,Type=String,Description="CNV">',
'##INFO=<ID=CNVLEN,Number=1,Type=Integer,Description="Region length in bp">',
'##INFO=<ID=PROBE,Number=1,Type=Integer,Description="Number of probes used for the region">',
'##INFO=<ID=REGION,Number=1,Type=String,Description="Target region name">',
'##INFO=<ID=GENE,Number=1,Type=String,Description="Gene symbol">',
'##FORMAT=<ID=CN,Number=1,Type=Integer,Description="Copy number; 5 means 5 or more">',
'##FORMAT=<ID=NR,Number=1,Type=Float,Description="Aggregated normalized intensity (about CN/2)">',
"\t".join(["#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO", "FORMAT", sample])] # fmt: skip
return "\n".join(h) + "\n"
def call_sample_regions(
d: npt.NDArray[np.float64],
model: PgxCnModel,
passing: bool,
baf: npt.NDArray[np.float64] | None = None,
bafs_in_cn: bool = False,
) -> list[dict[str, Any]]:
"""PCN-002/009/010: one result per region; ``baf`` = the sample's BAF at ``model.probe_idx``."""
vals = region_values(d, model)
out = []
for r in model.regions:
x, nf = vals[r.name]
n_all = len(model.region_sel(r.name))
fit = model.fits.get(r.name)
filters: list[str] = []
bll = None
n_snp = 0
if baf is not None:
sel = model.region_sel(r.name)
rb = baf[sel]
pf = model.probe_pfb[sel].astype(np.float64) if model.probe_pfb is not None else None
if pf is not None:
rb = np.where(np.isfinite(pf) & (pf > 0.01) & (pf < 0.99), rb, np.nan) # informative SNPs only
n_snp = int(np.isfinite(rb).sum())
bll = baf_loglik(rb, pf)
if fit is None or n_all < MIN_REGION_PROBES or nf < MIN_FINITE_FRAC * n_all or not math.isfinite(x):
cn, qual, nr = None, 0, math.nan
filters.append("NoCall")
else:
cn, qual, _ = call_cn(x, fit, bll if bafs_in_cn and n_snp else None)
# NR ≈ CN / 2: the continuous CN that the region's mixture means imply for x [OURS]
nr = max(0.0, 3.0 * 2.0 ** ((x - fit.delta) / fit.gamma) - 1.0) / 2.0 if cn else 0.0
if qual < 7:
filters.append("Q7")
if not passing:
filters.append("SampleQuality")
out.append({"region": r, "cn": cn, "qual": qual, "nr": nr, "x": x, "probes": nf, "filters": filters or ["PASS"],
"n_snp": n_snp,
"baf_ll": None if bll is None or not n_snp else [round(float(v), 3) for v in bll]}) # fmt: skip
return out
def _parse_date(text: str) -> Any:
from datetime import datetime as _dt
for fmt in ("%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
return _dt.strptime(text.strip(), fmt)
except ValueError:
continue
return None
def run_call(o: PgxCallOptions, rep: Reporter) -> int:
"""``simurg pgx copy-number call`` (CLI-031)."""
from simurg.core.atomic import atomic_write_text, check_outputs_absent
from simurg.formats.discovery import discover_gtcs
from simurg.formats.fasta import ContigMap, Genome
from simurg.formats.gtc import read_gtc
from simurg.pipeline.cn import write_vcf
from simurg.pipeline.qc import write_tsv
model = read_model(o.model)
genome = Genome(o.fasta)
if genome.build not in ("GRCh38", "unknown", ""):
raise SimurgError("E_BUILD_MISMATCH", f"PGx CN supports GRCh38 only (FASTA is {genome.build})")
cmap = ContigMap([e.name for e in genome.entries], "auto")
gtcs = discover_gtcs(o.gtcs)
ext = ".pgx_cn.vcf.gz" if o.bgzip else ".pgx_cn.vcf"
check_outputs_absent([o.out / f"{k}{ext}" for k in gtcs] + [o.out / "pgx_cn_batch_summary.tsv"], force=o.force)
n_probes = int(model.header["n_probes"])
mcl = model.header.get("cluster_file_name")
# pass 1: batch QC from GTC headers (PCN-004)
heads = {}
scan_dates: list[Any] = []
for k, p in gtcs.items():
g = read_gtc(p)
heads[k] = (g.logr_dev, g.sample_name or k, g.cluster_file, len(g.logr_ratios))
scan_dates.append(_parse_date(g.imaging_date))
n_pass = sum(1 for v in heads.values() if v[0] < o.lrr_sd_max)
if len(heads) < o.min_batch or n_pass < o.min_passing:
raise SimurgError("E_PGX_BATCH_TOO_SMALL", f"{n_pass} sample(s) passed QC. Requires at least "
f"{max(o.min_passing, 1)} samples to proceed.") # fmt: skip
dates = [x for x in scan_dates if x is not None]
if dates and (max(dates) - min(dates)).days > 14: # PCN-006 (sample-sheet lot/batch columns: no sheet input here)
span = (max(dates) - min(dates)).days
rep.warn("W_PGX_BATCH_COMPOSITION", f"scan dates span {span} days ({min(dates):%Y-%m-%d} … "
f"{max(dates):%Y-%m-%d}); batch statistics may mix processing batches") # fmt: skip
if n_pass < o.recommended_passing:
rep.warn("W_PGX_BATCH_BELOW_RECOMMENDED", f"{n_pass} passing sample(s); {o.recommended_passing} recommended")
for k, (_, _, cf, nl) in heads.items():
if nl != n_probes:
raise SimurgError("E_MODEL_MANIFEST_MISMATCH", f"{k} has {nl} loci, the model's manifest {n_probes}")
if isinstance(mcl, str) and cf and cf != mcl:
msg = f"Cluster file ({cf}) differs from the CN model's cluster file ({mcl})"
if o.cluster_mismatch == "error":
raise SimurgError("E_MODEL_CLUSTER_MISMATCH", msg, hint="retrain the model (PCN-005)")
rep.warn("W_MODEL_CLUSTER_MISMATCH", msg)
break
contigs: list[tuple[str, int]] = []
contig_name: dict[str, str | None] = {}
for r in model.regions:
if r.chrom in contig_name:
continue
c = cmap.get(r.chrom)
contig_name[r.chrom] = c
if c is None:
rep.warn("W_PGX_NO_ALT_CONTIG", f"{r.chrom} is not in {o.fasta.name}; REF written as N (IN-062)")
contigs.append((r.chrom, genome.contig_length(c) if c else r.end))
order = {c: i for i, (c, _) in enumerate(contigs)}
model_sha = hashlib.sha256(o.model.read_bytes()).hexdigest()
mh = model.header
meta = {
"fasta": str(o.fasta.resolve()),
"manifest": f"{mh['manifest_name']};sha256={mh['probe_set_sha256']}",
"model": f"{o.model.name};sha256={model_sha};cluster_file={mcl};regions={mh['regions_bed_version']}",
"batch": f"n_samples={len(heads)};n_passing={n_pass};lrr_sd_max={o.lrr_sd_max}",
}
centre_idx = model.probe_idx[model.region_sel("")] # the scale probes (autosomal), as in training
o.out.mkdir(parents=True, exist_ok=True)
baf_rows: list[dict[str, Any]] = []
srows, per_region = [], {r.name: {"n_gain": 0, "n_loss": 0, "n_neutral": 0, "n_nocall": 0, "n_filtered": 0}
for r in model.regions} # fmt: skip
failed = 0
for k, p in gtcs.items():
try:
g = read_gtc(p)
d = _centred(g.logr_ratios, centre_idx)[model.probe_idx] - model.probe_ref
passing = heads[k][0] < o.lrr_sd_max
bafs = None
if len(
g.b_allele_freqs
): # PCN-010: SNP probes only (called genotypes; intensity-only CNV probes never are)
bafs = np.asarray(g.b_allele_freqs, dtype=np.float64)[model.probe_idx]
bafs[np.asarray(g.genotypes)[model.probe_idx] == 0] = np.nan
res = call_sample_regions(d, model, passing, bafs, o.bafs_in_cn)
except SimurgError:
raise
except Exception as e: # PCN: one sample never stops the batch
failed += 1
rep.error("E_PGX_CN_FAILED", f"{k}: {e}", sample_id=k)
continue
for x in res:
if x["baf_ll"] is not None:
bl = x["baf_ll"]
baf_rows.append({"sample_id": k, "region": x["region"].name, "n_snp": x["n_snp"],
**{f"ll_cn{c}": bl[c] for c in range(len(bl))},
"best_cn_by_baf": int(np.argmax(bl[1:]) + 1)}) # fmt: skip
called = [(x["region"], x["cn"]) for x in res if x["cn"] is not None]
tot = sum(r.end - r.start0 for r, _ in called)
ploidy = sum((r.end - r.start0) * min(cn, CN_MAX) for r, cn in called) / tot if tot else math.nan
meta_s = {**meta, "ploidy": f"{ploidy:.3f}" if math.isfinite(ploidy) else "."}
recs = []
for x in sorted(res, key=lambda y: (order[y["region"].chrom], y["region"].start0)):
r = x["region"]
c = contig_name.get(r.chrom)
ref = (genome.fetch(c, r.start0 - 1, r.start0) or "N").upper() if c and r.start0 >= 1 else "N"
s, e_ = r.start0 + 1, r.end
cn = "." if x["cn"] is None else str(x["cn"])
nr = "." if not math.isfinite(x["nr"]) else f"{x['nr']:.6f}"
line = "\t".join([r.chrom, str(s - 1), f"CNV:{r.name}:{r.chrom}:{s}-{e_}", ref, "<CNV>", str(x["qual"]),
";".join(x["filters"]), f"END={e_};SVTYPE=CNV;CNVLEN={e_ - s + 1};PROBE={x['probes']};"
f"REGION={r.name};GENE={r.gene}", "CN:NR", f"{cn}:{nr}"]) # fmt: skip
recs.append((r.chrom, s - 2, e_, line))
pr = per_region[r.name]
if x["cn"] is None:
pr["n_nocall"] += 1
elif x["cn"] > 2:
pr["n_gain"] += 1
elif x["cn"] < 2:
pr["n_loss"] += 1
else:
pr["n_neutral"] += 1
pr["n_filtered"] += x["filters"] != ["PASS"]
write_vcf(o.out / f"{k}{ext}", _vcf_header(heads[k][1], contigs, meta_s), recs, o.bgzip)
srows.append({"sample_id": k, "sample_name": heads[k][1], "sample_folder": str(Path(p).parent),
"logr_dev": f"{heads[k][0]:.6f}", "passed_batch_qc": str(passing).lower(),
"used_in_reference": "false",
**{f"cn_{x['region'].name}": ("." if x["cn"] is None else x["cn"]) for x in res},
"flags": ""}) # fmt: skip
cols = ["region", "gene", "n_gain", "n_loss", "n_neutral", "n_nocall", "n_filtered"]
rows: list[dict[str, Any]] = [
{"region": r.name, "gene": r.gene, **per_region[r.name]} for r in sorted(model.regions, key=lambda r: r.name)
]
totals = {c: sum(per_region[r.name][c] for r in model.regions) for c in cols[2:]}
rows.append({"region": "ALL", "gene": "", **totals})
write_tsv(o.out / "pgx_cn_batch_summary.tsv", cols, rows)
scols = ["sample_id", "sample_name", "sample_folder", "logr_dev", "passed_batch_qc", "used_in_reference"]
scols += [f"cn_{r.name}" for r in model.regions] + ["flags"]
write_tsv(o.out / "pgx_cn_sample_summary.tsv", scols, srows)
write_tsv(o.out / "pgx_cn_baf_evidence.tsv", ["sample_id", "region", "n_snp"] + [f"ll_cn{c}" for c in range(6)]
+ ["best_cn_by_baf"], baf_rows) # PCN-010: for the star-allele caller and review # fmt: skip
run = {"software": f"SimurgArray {__version__}", **meta, "samples": len(gtcs), "failed": failed}
atomic_write_text(o.out / "pgx_cn_run.json", json.dumps(run, indent=1))
rep.info(f"PGx CN: {len(srows)}/{len(gtcs)} sample(s) called")
if failed == 0:
return ExitCode.OK
return ExitCode.PARTIAL if srows else ExitCode.ALL_FAILED
+421
View File
@@ -0,0 +1,421 @@
"""``simurg pgx db build`` — the PGx allele-definition database (spec PGX-001 … PGX-005, PGX-010, IN-053, CLI-035).
I/O and orchestration; the parsing and normalisation live in ``simurg.pgx.db``. Sources are the dated folders written
by ``scripts/pgx/fetch_sources.sh`` (``cpic-YYYYMMDD/`` with ``source.json``). The build is deterministic: rows are
inserted in sorted order into a fresh SQLite file with fixed pragmas, and the file is named by its content hash.
"""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from simurg import __version__
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
DB_SCHEMA = 1
CPIC_TABLES = ("gene", "allele", "allele_definition", "allele_location_value", "sequence_location", "gene_result",
"gene_result_lookup") # fmt: skip
GENE_PAD = 50_000 # probes within this distance of a gene's defining positions are resolved for probe_map
SCHEMA_SQL = """
CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE genes(gene TEXT PRIMARY KEY, result_basis TEXT NOT NULL, chrom TEXT);
CREATE TABLE alleles(gene TEXT, allele TEXT, kind TEXT, function TEXT, activity TEXT, structural INTEGER,
defined INTEGER, PRIMARY KEY(gene, allele));
CREATE TABLE allele_variants(gene TEXT, allele TEXT, build TEXT, chrom TEXT, pos INTEGER, ref TEXT, alt TEXT,
hgvs TEXT, rsid TEXT, location_id INTEGER, value TEXT);
CREATE TABLE allele_wildcards(gene TEXT, allele TEXT, location_id INTEGER);
CREATE TABLE sites(gene TEXT, location_id INTEGER, chrom TEXT, hgvs TEXT, rsid TEXT, ref_value TEXT,
PRIMARY KEY(gene, location_id));
CREATE TABLE site_values(gene TEXT, location_id INTEGER, value TEXT, pos INTEGER, ref TEXT, alt TEXT);
CREATE TABLE probe_map(manifest TEXT, probe_name TEXT, chrom TEXT, pos INTEGER, ref TEXT, alt TEXT);
CREATE TABLE masks(manifest TEXT, probe_name TEXT, reason TEXT);
CREATE TABLE limitations(manifest TEXT, gene TEXT, allele TEXT, status TEXT, reported_as TEXT);
CREATE TABLE frequencies(gene TEXT, allele TEXT, population TEXT, freq REAL);
CREATE TABLE phenotypes(guideline TEXT, gene TEXT, lookup_key TEXT, activity_score TEXT, phenotype TEXT);
CREATE INDEX av_pos ON allele_variants(chrom, pos);
CREATE INDEX pm_pos ON probe_map(manifest, chrom, pos);
"""
@dataclass
class PgxDbOptions:
sources: Path
fasta: Path
out: Path
manifests: list[Path] = field(default_factory=list)
csvs: list[Path] = field(default_factory=list)
masks: list[Path] = field(default_factory=list)
force: bool = False
def _latest(sources: Path, prefix: str) -> Path | None:
cands = sorted(p for p in sources.glob(f"{prefix}-*") if (p / "source.json").exists())
return cands[-1] if cands else None
def _load_cpic(d: Path) -> dict[str, list[dict[str, Any]]]:
t = {}
for name in CPIC_TABLES:
p = d / f"{name}.json"
if not p.exists():
raise SimurgError("E_PGX_SOURCE", f"{p} missing", hint="run scripts/pgx/fetch_sources.sh")
t[name] = json.loads(p.read_text(encoding="utf-8"))
return t
# PGX-007 tagging variants (dbSNP plus-strand alleles); positions are resolved from the manifest's probes
TAGS = [("HLA-A", "*31:01", "rs1061235", "A", "T"), ("HLA-B", "*15:02", "rs144012689", "T", "A"),
("HLA-B", "*57:01", "rs2395029", "T", "G"), ("HLA-B", "*58:01", "rs9263726", "G", "A")] # fmt: skip
TAG_CAVEAT = "tag reliability varies by population"
def _probe_sites(
bpm_path: Path,
csv_path: Path | None,
genome: Any,
cmap: Any,
windows: dict[str, list[tuple[int, int]]],
rep: Reporter,
rsids: set[str] | None = None,
) -> list[Any]:
"""Plus-strand, normalised sites of the manifest probes inside the gene windows (the SNV VCF representation)."""
from simurg.formats.bpm import read_bpm
from simurg.harmonize.sites import ManifestView, map_locus
bpm = read_bpm(bpm_path)
csvm = None
if csv_path is not None:
from simurg.formats.csv_manifest import read_csv_manifest
csvm = read_csv_manifest(csv_path)
elif any("D" in s for s in bpm.snps):
rep.warn("W_SKIP_INDEL_CONTEXT", f"{bpm_path.name}: no CSV manifest, indel probes are not mapped")
src = csvm or bpm
mv = ManifestView(bpm.names, bpm.snps, bpm.chroms, bpm.map_infos.tolist(), src.ilmn_strands, src.source_strands,
src.ref_strands if any(src.ref_strands) else bpm.ref_strands,
csvm.source_seqs if csvm else [""] * bpm.num_loci, bpm.assay_types.tolist(),
bpm.intensity_only.tolist()) # fmt: skip
out = []
tok = re.compile(r"rs\d+")
for i in range(bpm.num_loci):
c = cmap.get(bpm.chroms[i])
if rsids is not None:
if c is None or not (set(tok.findall(bpm.names[i])) & rsids):
continue
elif c is None or c not in windows:
continue
else:
p = int(bpm.map_infos[i])
if not any(a - GENE_PAD <= p <= b + GENE_PAD for a, b in windows[c]):
continue
site, _ = map_locus(i, mv, genome.fetch, cmap.get, genome.build or "GRCh38")
if site is not None:
out.append(site)
return out
def run_db_build(o: PgxDbOptions, rep: Reporter) -> int:
"""``simurg pgx db build`` (CLI-035)."""
from simurg.formats.bpm import read_bpm
from simurg.formats.fasta import ContigMap, Genome
from simurg.pgx.db import cpic_definitions, full_hgvs, result_basis
cdir = _latest(o.sources, "cpic")
if cdir is None:
raise SimurgError("E_PGX_SOURCE", f"no cpic-YYYYMMDD source folder in {o.sources}",
hint="run scripts/pgx/fetch_sources.sh") # fmt: skip
csrc = json.loads((cdir / "source.json").read_text())
genome = Genome(o.fasta)
if genome.build not in ("GRCh38", "unknown", ""):
raise SimurgError("E_BUILD_MISMATCH", f"the PGx DB is GRCh38 only (FASTA is {genome.build})") # PCN-015
cmap = ContigMap([e.name for e in genome.entries], "auto")
t = _load_cpic(cdir)
defs, sites, problems = cpic_definitions(t, genome.fetch, cmap.get)
for p in problems:
rep.info(f"definition note: {p}")
rows: dict[str, list[tuple[Any, ...]]] = {k: [] for k in ("genes", "alleles", "allele_variants", "allele_wildcards",
"sites", "site_values", "frequencies", "phenotypes",
"probe_map", "masks")} # fmt: skip
gene_chr = {g["symbol"]: g.get("chr") for g in t["gene"]}
for gene, ds in sorted(defs.items()):
rows["genes"].append((gene, result_basis(gene, [d.name for d in ds]), gene_chr.get(gene)))
for d in ds:
kind = "reference" if d.reference else ("star_allele" if d.name.startswith("*") else "named_haplotype")
rows["alleles"].append(
(gene, d.name, kind, d.function, d.activity, int(d.structural), int(not d.undefined))
)
for lid, val in d.variants:
s = sites.get(lid)
if s is None or val not in s.alleles:
continue
pos, ref, alt = s.alleles[val]
rows["allele_variants"].append(
(gene, d.name, "GRCh38", s.chrom, pos, ref, alt, full_hgvs(s.chrom, s.hgvs), s.rsid, lid, val)
)
for lid in d.wildcards:
rows["allele_wildcards"].append((gene, d.name, lid))
for pop, f in sorted(d.frequencies.items()):
rows["frequencies"].append((gene, d.name, pop, f))
for lid, s in sorted(sites.items()):
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()):
rows["site_values"].append((s.gene, lid, val, pos, ref, alt))
# PharmVar genes that CPIC does not define (PGX-001): core alleles from the GRCh38 haplotype VCFs
pv_src = None
pdir = _latest(o.sources, "pharmvar")
if pdir is not None:
import zipfile
from simurg.pgx.db import normalize, pharmvar_core_alleles
pv_src = json.loads((pdir / "source.json").read_text())
lid = 900_000_000
for zpath in sorted(pdir.glob("*.zip")):
gene = zpath.stem
if gene in defs:
continue
with zipfile.ZipFile(zpath) as z:
vcfs = {n: z.read(n).decode() for n in z.namelist() if "/GRCh38/" in n and n.endswith(".vcf")}
core = pharmvar_core_alleles(gene, vcfs)
if not core:
continue
core.setdefault("*1", []) # the reference haplotype has no core VCF of its own
chrom = None
n_var = 0
for allele in sorted(core, key=lambda a: int(a[1:])):
bad = False
norm: list[tuple[str, int, str, str, str | None, str]] = []
for c, vpos, r, alt_v, rsid in core[allele]:
contig = cmap.get(c)
if contig is None or genome.fetch(contig, vpos - 1, vpos - 1 + len(r)) != r:
bad = True
break
npos, nref, nalt = normalize(genome.fetch, contig, vpos, vpos + len(r) - 1, alt_v)
norm.append((contig, npos, nref, nalt, rsid, f"g.{vpos}{r}>{alt_v}"))
chrom = c
if bad:
rep.info(f"definition note: PharmVar {gene} {allele}: REF does not match the FASTA, skipped")
continue
kind = "reference" if allele == "*1" else "star_allele"
rows["alleles"].append((gene, allele, kind, None, None, 0, int(bool(norm) or allele == "*1")))
for contig, pos, ref, alt, rsid, hg in norm:
lid += 1
rows["allele_variants"].append((gene, allele, "GRCh38", contig, pos, ref, alt,
full_hgvs(contig, hg), rsid, lid, alt)) # fmt: skip
n_var += 1
rows["genes"].append((gene, "star_allele", chrom))
rep.info(f"PharmVar {gene}: {len(core)} core allele(s), {n_var} variant row(s)")
results = {r["id"]: r for r in t["gene_result"]}
for lk in t["gene_result_lookup"]:
res = results.get(lk["phenotypeid"])
if res is None:
continue
key = json.dumps(lk.get("lookupkey"), sort_keys=True, ensure_ascii=False)
rows["phenotypes"].append(("cpic", res["genesymbol"], key, lk.get("totalactivityscore"), res["result"]))
# probe map (PGX-003): manifest probes whose normalised site carries a DB variant
windows: dict[str, list[tuple[int, int]]] = {}
for av in rows["allele_variants"]:
windows.setdefault(av[3], []).append((av[4], av[4] + len(av[5])))
var_keys = {(av[3], av[4], av[5], av[6]) for av in rows["allele_variants"]}
manifests = []
tag_rows: dict[tuple[str, str], tuple[Any, ...]] = {}
csvs = dict(zip(o.manifests, o.csvs, strict=False))
for bpm_path in o.manifests:
name = read_bpm(bpm_path).manifest_name
manifests.append(name)
n = 0
for ps in _probe_sites(bpm_path, csvs.get(bpm_path), genome, cmap, windows, rep):
for alt in ps.alleles:
if alt != ps.ref and (ps.contig, ps.pos, ps.ref, alt) in var_keys:
rows["probe_map"].append((name, ps.name, ps.contig, ps.pos, ps.ref, alt))
n += 1
rep.info(f"{name}: {n} probe-to-variant link(s)")
comp = str.maketrans("ACGT", "TGCA")
for ps in _probe_sites(bpm_path, csvs.get(bpm_path), genome, cmap, {}, rep, {t[2] for t in TAGS}):
for gene, allele, rsid, t_ref, t_alt in TAGS:
if rsid not in re.findall(r"rs\d+", ps.name) or len(ps.ref) != 1:
continue
if ps.ref == t_ref:
alt = t_alt
elif ps.ref == t_ref.translate(comp):
alt = t_alt.translate(comp)
else:
continue
if alt not in ps.alleles:
continue
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
rows["alleles"]] # fmt: skip
rep.info(f"tagging variants resolved: {', '.join(f'{g} {a}' for g, a in sorted(tag_rows))}")
for mp in o.masks:
for ln in mp.read_text(encoding="utf-8").splitlines():
if ln.strip() and not ln.startswith("#"):
cols = ln.split("\t")
rows["masks"].append((cols[2] if len(cols) > 2 else mp.stem, cols[0], cols[1] if len(cols) > 1 else ""))
meta = {"schema": "simurg.pgx_db", "schema_version": str(DB_SCHEMA), "builder": f"SimurgArray {__version__}",
"genome_build": "GRCh38", "fasta_sha256": genome.sha256, "sqlite_version": sqlite3.sqlite_version,
"allele_sources": json.dumps([{"name": "CPIC", "version": csrc["version"], "retrieved": csrc["retrieved"],
"license": csrc.get("license")}]
+ ([{"name": "PharmVar", "version": pv_src["version"],
"retrieved": pv_src["retrieved"], "license": pv_src.get("license")}]
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
# content hash over the sorted rows and meta (IN-053: the name and meta carry it; a file cannot hold its own hash)
for rs in rows.values():
rs.sort(key=lambda r: tuple(str(x) for x in r))
sha = hashlib.sha256(json.dumps({"rows": rows, "meta": sorted(meta.items())}, sort_keys=True,
default=str).encode()).hexdigest() # fmt: skip
meta["content_sha256"] = sha
o.out.mkdir(parents=True, exist_ok=True)
tmp = o.out / ".pgx_db.part.sqlite"
tmp.unlink(missing_ok=True)
con = sqlite3.connect(tmp)
con.execute("PRAGMA page_size=4096")
con.execute("PRAGMA journal_mode=OFF")
con.executescript(SCHEMA_SQL)
for table, rs in rows.items():
if rs:
ph = ",".join("?" * len(rs[0]))
con.executemany(f"INSERT INTO {table} VALUES ({ph})", rs)
con.executemany("INSERT INTO meta VALUES (?, ?)", sorted(meta.items()))
con.commit()
con.execute("VACUUM")
con.close()
final = o.out / f"pgx_db-{DB_SCHEMA}-{sha[:12]}.sqlite"
if final.exists() and not o.force:
tmp.unlink()
rep.info(f"identical PGx DB already present: {final}")
return ExitCode.OK
tmp.replace(final)
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
# ------------------------------------------------------------------------------------------------ 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 = []
from simurg.pipeline.pgx_star import ALL_BASES
for gene, m in sorted(load_models(db, "global", None, ALL_BASES).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
+161
View File
@@ -0,0 +1,161 @@
"""``simurg pgx phenotype`` (alias ``annotate``) — phenotypes and the per-sample PGx JSON (spec PGX-024 … PGX-028,
CLI-034). Reads the ``pgx star-allele call`` output folder and the PGx DB; can be re-run with another guideline.
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from simurg import __version__
from simurg.core.errors import ExitCode, SimurgError
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"
@dataclass
class PhenoOptions:
calls: Path # folder written by pgx star-allele call
db: Path
out: Path | None = None
guideline: str = "cpic"
force: bool = False
def db_meta(db: Path) -> dict[str, str]:
con = sqlite3.connect(db)
meta = dict(con.execute("SELECT key, value FROM meta").fetchall())
con.close()
return meta
def tsv_header(db: Path, meta: dict[str, str], guideline: str) -> list[str]:
"""PGX-026: ``#`` metadata lines at the top of every PGx TSV."""
sha = meta.get("content_sha256") or hashlib.sha256(db.read_bytes()).hexdigest()
return [f"#simurg_schema={TSV_SCHEMA}", f"#software_version={__version__}", "#genome_build=GRCh38",
f"#pgx_db={db.name};sha256={sha}", f"#allele_sources={meta.get('allele_sources', '')}",
f"#phenotype_sources={meta.get('phenotype_sources', '')}", f"#guideline={guideline}"] # fmt: skip
def write_meta_tsv(path: Path, header: list[str], cols: list[str], rows: list[dict[str, Any]]) -> None:
from simurg.core.atomic import atomic_write_text
def cell(v: Any) -> str:
if v is None:
return ""
if isinstance(v, float):
if v != v:
return ""
txt = f"{v:.4f}".rstrip("0")
return txt + "0" if txt.endswith(".") else txt
return str(v).replace("\t", " ")
lines = header + ["\t".join(cols)] + ["\t".join(cell(r.get(c)) for c in cols) for r in rows]
atomic_write_text(path, "\n".join(lines) + "\n")
def run_phenotype(o: PhenoOptions, rep: Reporter) -> int:
"""``simurg pgx phenotype`` (CLI-034)."""
from simurg.core.atomic import atomic_write_text
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")
out = o.out or o.calls
out.mkdir(parents=True, exist_ok=True)
calls = json.loads(src.read_text())
meta = db_meta(o.db)
con = sqlite3.connect(o.db)
tables = {}
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 = '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,))]
for (g,) in con.execute("SELECT gene FROM genes")} # fmt: skip
con.close()
sha = meta.get("content_sha256", "")
ann_rows = []
for s in calls["samples"]:
genes = []
for g in s["genes"]:
t = tables.get(g["gene"])
feas = [tuple(x.split("/")) for x in g["feasible_solutions"]]
per: list[str] = []
if t is None:
res = None
else:
res, per = gene_phenotype(t, feas)
if g.get("call_type") == "tagging_variant": # PGX-007: the CPIC result is the tag status itself
from simurg.pgx.phenotype import PhenotypeResult
res, per = PhenotypeResult(g["genotype"], None), []
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"],
"feasible_solutions": g["feasible_solutions"],
"activity_score": res.activity_score if res else None,
"phenotype": res.phenotype if res else None, "per_solution_phenotypes": per,
"score": top.get("score"), "raw_score": top.get("raw_score"),
"flags": sorted(set(flags)), "copy_number": g.get("copy_number"),
"array_equivalent_alleles": [{"allele": a["allele"], "equivalents": a["array_equivalent_alleles"]}
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)
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"],
"activity_score": entry["activity_score"], "phenotype": entry["phenotype"],
"flags": ";".join(entry["flags"])}) # fmt: skip
doc = {"schema": JSON_SCHEMA, "software_version": __version__, "genome_build": "GRCh38",
"pgx_db": {"file": o.db.name, "sha256": sha},
"allele_sources": json.loads(meta.get("allele_sources", "[]")),
"phenotype_sources": json.loads(meta.get("phenotype_sources", "[]")), "guideline": o.guideline,
"sample_id": s["sample_id"], "sample_name": s.get("sample_name", s["sample_id"]),
"genes": genes} # fmt: skip
atomic_write_text(out / f"{s['sample_id']}.pgx.json", json.dumps(doc, indent=1, ensure_ascii=False))
cols = ["sample_id", "sample_name", "gene", "guideline", "genotype", "activity_score", "phenotype", "flags"]
ann_rows.sort(key=lambda r: (r["sample_id"], r["gene"]))
write_meta_tsv(out / "diplotype_annotations.tsv", tsv_header(o.db, meta, o.guideline), cols, ann_rows)
rep.info(f"phenotypes ({o.guideline}): {len(calls['samples'])} sample(s) -> {out}")
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, ...]:
import re
return tuple(int(x) if x.isdigit() else x for x in re.split(r"(\d+)", a))
+585
View File
@@ -0,0 +1,585 @@
"""``simurg pgx star-allele call`` — star alleles from SNV VCFs, PGx DB and PGx CN VCFs (spec PGX-011 … PGX-021,
CLI-033). I/O and orchestration; the scoring lives in ``simurg.pgx.star``.
Scope of this version: genes whose result basis is ``star_allele`` in the DB. Named-haplotype (G6PD), variant-based
and tagging genes, sub-allele collapsing and hybrid CYP2D6 structures follow (K-029).
"""
from __future__ import annotations
import contextlib
import gzip
import json
import math
import re
import sqlite3
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
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,
cyp2d6_structure,
)
MAX_EDIT_DISTANCE = 3 # PGX-019
CN_REGION = {"CYP2D6": "CYP2D6.p5", "CYP2A6": "CYP2A6.exon.1"} # PGX-013: gene copies from the 5′ region
CN_SOLUTION_REGIONS = {
"CYP2D6": ("CYP2D6.p5", "CYP2D6.intron.2", "CYP2D6.exon.9"),
"CYP2A6": ("CYP2A6.exon.1", "CYP2A6.intron.7"),
} # 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"
DELETION_ALLELE = {"CYP2D6": "*5", "CYP2A6": "*4"} # named whole-gene deletions (PGX-006)
@dataclass
class StarOptions:
vcfs: list[Path]
db: Path
out: Path
population: str = "global"
max_candidates: int = 10
feasible_ratio: float = 0.5
low_gs: float = 0.15
no_cn: bool = False
allow_foreign_vcf: bool = False
genes: list[str] = field(default_factory=list)
force: bool = False
def load_models(
db: Path, population: str, genes: list[str] | None = None, bases: tuple[str, ...] = ("star_allele",)
) -> dict[str, GeneModel]:
con = sqlite3.connect(db)
q = f"SELECT gene, result_basis FROM genes WHERE result_basis IN ({','.join('?' * len(bases))}) ORDER BY gene"
out = {}
for gene, basis in con.execute(q, bases).fetchall():
if genes and gene not in genes:
continue
alle = con.execute("SELECT allele, kind FROM alleles WHERE gene = ? AND defined = 1 AND structural = 0 "
"ORDER BY allele", (gene,)).fetchall() # fmt: skip
names = [a for a, _ in alle]
ref = next((a for a, k in alle if k == "reference"), names[0] if names else "")
rows = con.execute("SELECT allele, chrom, pos, ref, alt, hgvs, rsid, location_id FROM allele_variants "
"WHERE gene = ?", (gene,)).fetchall() # fmt: skip
keys = sorted({(r[1], r[2], r[3], r[4]) for r in rows}, key=lambda k: (k[0], k[1], k[2], k[3]))
kidx = {k: i for i, k in enumerate(keys)}
ids: list[tuple[str, str | None]] = [("", None)] * len(keys)
loc_keys: dict[int, list[int]] = defaultdict(list)
carries = np.zeros((len(names), len(keys)), dtype=np.int8)
hidx = {n: i for i, n in enumerate(names)}
for allele, chrom, pos, r_, a_, hgvs, rsid, lid in rows:
k = kidx[(chrom, pos, r_, a_)]
ids[k] = (hgvs, rsid)
loc_keys[lid].append(k)
if allele in hidx:
carries[hidx[allele], k] = 1
for allele, lid in con.execute("SELECT allele, location_id FROM allele_wildcards WHERE gene = ?", (gene,)):
if allele in hidx:
for k in loc_keys.get(lid, []):
carries[hidx[allele], k] = -1
freq = np.full(len(names), np.nan)
fr: dict[str, list[float]] = defaultdict(list)
for allele, pop, f in con.execute("SELECT allele, population, freq FROM frequencies WHERE gene = ?", (gene,)):
if population in ("global", pop):
fr[allele].append(float(f))
for i, n in enumerate(names):
if fr.get(n):
freq[i] = float(np.mean(fr[n]))
freq = np.nan_to_num(freq, nan=0.0)
if not freq.any() and names: # no population frequencies (PharmVar genes): reference 0.8, others share 0.2
freq = np.full(len(names), 0.2 / max(1, len(names) - 1))
if ref in names:
freq[names.index(ref)] = 0.8
fa = {a: (f, v) for a, f, v in con.execute("SELECT allele, function, activity FROM alleles WHERE gene = ?",
(gene,))} # fmt: skip
act = {}
for a, (_, v) in fa.items():
with contextlib.suppress(TypeError, ValueError):
act[a] = float(v)
out[gene] = GeneModel(gene, keys, ids, names, carries, freq, DELETION_ALLELE.get(gene), ref, basis, act,
{a: f for a, (f, _) in fa.items() if f}) # fmt: skip
con.close()
return out
def read_vcf_sites(
path: Path, wanted: dict[str, set[int]]
) -> tuple[dict[tuple[str, int], list[dict[str, Any]]], dict[str, str]]:
"""Records at the wanted positions, and the ``##`` header meta (``##key=value``)."""
recs: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
meta: dict[str, str] = {}
with gzip.open(path, "rt") as fh:
for ln in fh:
if ln.startswith("##"):
k, _, v = ln[2:].rstrip("\n").partition("=")
meta.setdefault(k, v)
continue
if ln.startswith("#"):
continue
c, p, _ = ln.split("\t", 2)
ps = wanted.get(c)
if ps is None or int(p) not in ps:
continue
f = ln.rstrip("\n").split("\t")
fmt = f[8].split(":")
val = dict(zip(fmt, f[9].split(":"), strict=False))
recs[(c, int(p))].append({"id": f[2], "ref": f[3], "alts": f[4].split(","), "gt": val.get("GT", "./."),
"gs": val.get("GS"), "baf": val.get("BAF")}) # fmt: skip
return recs, meta
def observations(
m: GeneModel, recs: dict[tuple[str, int], list[dict[str, Any]]], masked: set[str] | None = None
) -> tuple[Observations, int]:
"""PGX-012 evidence per defining variant; returns the observations and the ploidy seen in the GTs. A record with
any masked probe (PGX-010) makes the variant ``masked``."""
v = len(m.variants)
state = np.full(v, NOT_COVERED, dtype=np.int8)
dosage = np.full(v, -1, dtype=np.int8)
gs = np.full(v, np.nan)
baf = np.full(v, np.nan)
ploidy = 2
for k, (c, p, r_, a_) in enumerate(m.variants):
for rec in recs.get((c, p), []):
if rec["ref"] != r_ or a_ not in rec["alts"]:
continue
ai = rec["alts"].index(a_) + 1
if masked and any(pn in masked for pn in rec["id"].replace(";", ",").split(",")):
state[k] = MASKED
break
alleles = rec["gt"].replace("|", "/").split("/")
ploidy = len(alleles)
if any(a in (".", "") for a in alleles):
state[k] = NO_CALL
break
state[k] = CALLED
dosage[k] = sum(int(a) == ai for a in alleles)
gs[k] = float(rec["gs"]) if rec["gs"] not in (None, ".") else math.nan
baf[k] = float(rec["baf"]) if rec["baf"] not in (None, ".") else math.nan
if len(rec["alts"]) > 1 and not math.isnan(baf[k]):
baf[k] = math.nan # BAF of a merged multi-allelic record is not per-ALT
break
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:
unit = name.split("+")[-1] # "*68+*4" → the full copy "*4"; "*2x2"/"*1xN" → "*2"/"*1"
mx = re.fullmatch(r"(.+?)x(\d+|N)", unit)
core = mx.group(1) if mx else unit
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]:
out: dict[str, int | None] = {}
with gzip.open(path, "rt") as fh:
for ln in fh:
if ln.startswith("#"):
continue
f = ln.rstrip("\n").split("\t")
region = f[7].split("REGION=")[1].split(";")[0]
cn = f[9].split(":")[0]
out[region] = None if cn == "." or "NoCall" in f[6] else int(cn)
return out
def _discover(dirs: list[Path]) -> tuple[dict[str, Path], dict[str, Path]]:
snv, cn = {}, {}
for d in dirs:
for p in sorted(d.rglob("*.vcf.gz")):
if p.name.endswith(".pgx_cn.vcf.gz"):
cn[p.name[: -len(".pgx_cn.vcf.gz")]] = p
elif p.name.endswith(".snv.vcf.gz"):
snv[p.name[: -len(".snv.vcf.gz")]] = p
return snv, cn
def run_star_call(o: StarOptions, rep: Reporter) -> int:
"""``simurg pgx star-allele call`` (CLI-033)."""
from simurg.core.atomic import check_outputs_absent
from simurg.pipeline.pgx_pheno import db_meta, tsv_header, write_meta_tsv
snv, cnv = _discover(o.vcfs)
if not snv:
raise SimurgError("E_NO_INPUT", f"no *.snv.vcf.gz under {', '.join(map(str, o.vcfs))}")
missing_cn = sorted(set(snv) - set(cnv))
if missing_cn and not o.no_cn:
raise SimurgError("E_PGX_CN_MISSING", f"{len(missing_cn)} sample(s) have no .pgx_cn.vcf.gz (e.g. "
f"{missing_cn[0]})", hint="run pgx copy-number call, or give --no-cn") # fmt: skip
for vp in snv.values():
if not Path(str(vp) + ".tbi").exists() and not Path(str(vp) + ".csi").exists():
raise SimurgError("E_PGX_VCF_UNINDEXED", f"{vp} is not indexed")
check_outputs_absent([o.out / "diplotype_summary.tsv"], force=o.force)
models = load_models(o.db, o.population, o.genes or None, ALL_BASES)
mcon = sqlite3.connect(o.db)
masks: dict[str, set[str]] = defaultdict(set)
for man, probe in mcon.execute("SELECT manifest, probe_name FROM masks"):
masks[man].add(probe)
mcon.close()
wanted: dict[str, set[int]] = defaultdict(set)
for m in models.values():
for c, p, _, _ in m.variants:
wanted[c].add(p)
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 = []
first_ident: tuple[str, str] | None = None
for sid, path in sorted(snv.items()):
recs, meta = read_vcf_sites(path, wanted)
opts = meta.get("simurg_options", "")
if not opts and not o.allow_foreign_vcf:
raise SimurgError("E_PGX_VCF_UNSUITABLE", f"{path}: not a SimurgArray VCF (no ##simurg_options)",
hint="give --allow-foreign-vcf to use it anyway") # fmt: skip
kv = dict(x.partition("=")[::2] for x in opts.split(";") if x)
if (
kv.get("unsquash_duplicates") == "true"
or kv.get("exclude_loci", "none").lower() != "none"
or kv.get("include_loci", "none").lower() != "none"
): # PGX-011
raise SimurgError("E_PGX_VCF_UNSUITABLE", f"{path}: made with unsquashed duplicates or a locus filter")
ident = (meta.get("simurg_manifest", "").split(";")[0], meta.get("simurg_genome_build", ""))
if first_ident is None:
first_ident = ident
elif ident != first_ident and not o.allow_foreign_vcf: # PGX-011 (4)
raise SimurgError("E_PGX_VCF_UNSUITABLE", f"{path}: manifest/build {ident} differs from {first_ident}",
hint="call one manifest and build per run") # fmt: skip
cn = {} if o.no_cn or sid not in cnv else read_cn_vcf(cnv[sid])
masked = masks.get(meta.get("simurg_manifest", "").split(";")[0], set())
sample_name = sid
genes_out = []
for gene, m in sorted(models.items()):
gene_json, g_rows, g_sup, g_unres, g_ncov = call_one_gene(sid, sample_name, m, recs, masked, cn, o)
if gene_json is None:
continue
genes_out.append(gene_json)
rows += g_rows
sup_rows += g_sup
unres_rows += g_unres
for x in g_ncov:
not_cov_rows[(gene, str(x["hgvs"]))] = x
if not o.no_cn:
for gene, region in CN_ONLY_GENES.items():
if o.genes and gene not in o.genes:
continue
gj, row = cn_only_gene(sid, sample_name, gene, cn.get(region))
genes_out.append(gj)
rows.append(row)
calls_json.append({"sample_id": sid, "genes": genes_out})
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",
"variant_change", "score", "raw_score", "copy_number_solution", "flags"] # fmt: skip
o.out.mkdir(parents=True, exist_ok=True)
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,
"samples": calls_json}, indent=1)) # fmt: skip
rep.info(f"star alleles: {len(calls_json)} sample(s), {len(models)} gene(s)")
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)."""
if m.basis in ("variant_based", "tagging_variant"):
return call_variant_gene(sid, sample_name, m, recs, masked, o)
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
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, [], [], [], []
if gcn is not None and gcn >= 5: # PGX-022: CN 5 means "5 or more" → the duplication count is unknown (xN)
import re as _re
for s_ in sols:
s_.haplotypes = tuple(_re.sub(r"x\d+$", "xN", h) for h in s_.haplotypes)
s_.members = [tuple(_re.sub(r"x\d+$", "xN", h) for h in mm) for mm in s_.members]
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": m.basis, "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": ";".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"]:
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": m.basis, "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 call_variant_gene(
sid: str, sample_name: str, m: GeneModel, recs: dict[tuple[str, int], list[dict[str, Any]]], masked: set[str],
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
"""PGX-007 / PGX-021: variant-based and tagging genes are reported per variant (``rs9923231 C/T`` or
``Reference``); the diplotype for the phenotype lookup is built from the carried variant alleles — with more than
two, the two of lowest activity (CPIC DPYD rule), non-normal function before normal [DB: CPIC DPYD guideline]."""
obs, ploidy = observations(m, recs, masked)
vrec = variant_records(m, recs)
carried: list[str] = []
variant_text: list[str] = []
sup_rows: list[dict[str, Any]] = []
for k, (c, p, r_, a_) in enumerate(m.variants):
if obs.state[k] != CALLED or obs.dosage[k] <= 0:
continue
rec = vrec[k]
hg, rs = m.variant_ids[k]
gt = "/".join([r_] * (ploidy - int(obs.dosage[k])) + [a_] * int(obs.dosage[k]))
variant_text.append(f"{rs or hg} {gt}")
owners = [m.haplotypes[h] for h in np.flatnonzero(m.carries[:, k] == 1).tolist()]
carried += owners[:1] * int(obs.dosage[k])
sup_rows.append({"sample_id": sid, "solution_id": 1, "gene": m.gene, "allele": owners[0] if owners else "",
"hgvs": hg, "rsid": rs, "chrom": c, "pos": p, "ref": r_, "alts": ";".join(rec["alts"]) if rec
else a_, "genotype": rec["gt"] if rec else "", "variant_copy_number": int(obs.dosage[k]),
"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), "probe_ids": rec["id"] if rec
else "", "low_gs": str(bool(obs.gs[k] < o.low_gs)).lower()}) # fmt: skip
def severity(a: str) -> tuple[float, int]:
f = (m.function.get(a) or "").lower()
return (m.activity.get(a, 1.0), 0 if f and "normal" not in f else 1)
carried.sort(key=severity)
ref = m.reference or "Reference"
dip = (carried + [ref] * ploidy)[:ploidy]
if m.basis == "tagging_variant":
genotype = ";".join(f"{a} {'positive' if a in carried else 'negative'}" for a in m.haplotypes)
else:
genotype = "; ".join(variant_text) if variant_text else "Reference"
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]
flags = ["unresolved_due_to_no_call"] if v_nc else []
if m.basis == "tagging_variant":
flags.append("tag_caveat")
def probe_ids(k: int) -> list[str]:
rec = vrec[k]
return str(rec["id"]).split(",") if rec else []
no_call_list = [{"hgvs": m.variant_ids[k][0], "rsid": m.variant_ids[k][1], "probe_ids": probe_ids(k),
"alleles_impacted": [m.haplotypes[h] for h in np.flatnonzero(m.carries[:, k] == 1).tolist()]}
for k in v_nc] # fmt: skip
not_cov = [{"hgvs": m.variant_ids[k][0], "rsid": m.variant_ids[k][1],
"alleles_impacted": [m.haplotypes[h] for h in np.flatnonzero(m.carries[:, k] == 1).tolist()]}
for k in v_ncov] # fmt: skip
diplotype = "/".join(dip)
imp_nc = sorted({m.haplotypes[h] for k in v_nc for h in np.flatnonzero(m.carries[:, k] == 1).tolist()})
cand = {"genotype": diplotype, "score": 1.0, "raw_score": 1.0, "equivalents": [diplotype], "copy_number": 2,
"alleles": [{"allele": a, "supporting_variants": [], "unresolved_variant_sites": [],
"array_equivalent_alleles": [a], "background_allele": None} for a in dip],
"variant_change": None, "flags": [], "variant_no_calls": no_call_list,
"alleles_impacted_by_no_calls": imp_nc} # fmt: skip
row = {"sample_id": sid, "sample_name": sample_name, "solution_id": 1, "gene": m.gene,
"call_type": "variant" if m.basis == "variant_based" else m.basis, "solution": genotype,
"array_equivalent_alleles_1": "", "array_equivalent_alleles_2": "",
"no_call_variants": ";".join(str(x["hgvs"]) for x in no_call_list),
"alleles_impacted_by_no_call": ";".join(imp_nc), "background_alleles": "",
"variant_change": "", "score": "1.0000", "raw_score": "1.0000", "copy_number_solution": "",
"flags": ";".join([*flags, "feasible"])} # fmt: skip
gene_json = {"gene": m.gene, "call_type": "variant" if m.basis == "variant_based" else m.basis,
"genotype": genotype, "feasible_solutions": [diplotype], "candidates": [cand], "copy_number": None,
"flags": flags, "variant_no_calls": no_call_list, "variants_not_covered": not_cov,
"unexplained_variants": [], "alleles_tested": sorted(m.haplotypes, key=_natural),
"caveat": TAG_CAVEAT if m.basis == "tagging_variant" else None} # fmt: skip
ncov_rows = [{"gene": m.gene, "hgvs": m.variant_ids[k][0], "rsid": m.variant_ids[k][1],
"alleles_impacted": ";".join(m.haplotypes[h] for h in np.flatnonzero(m.carries[:, k] == 1).tolist())}
for k in v_ncov] # fmt: skip
return gene_json, [row], sup_rows, [], ncov_rows
def cn_only_gene(sid: str, sample_name: str, gene: str, cn: int | None) -> tuple[dict[str, Any], dict[str, Any]]:
"""PGX-006: genes with a whole-gene CN region and no named deletion allele — CN 0 → *0/*0, 1 → *1/*0,
2 → *1/*1, n ≥ 3 → *1x(n−1)/*1 (N when capped)."""
if cn is None:
geno = ""
elif cn == 0:
geno = "*0/*0"
elif cn == 1:
geno = "*1/*0"
elif cn == 2:
geno = "*1/*1"
else:
geno = f"*1x{'N' if cn >= 5 else cn - 1}/*1"
flags = [] if geno else ["no_call"]
cand = {"genotype": geno, "score": 1.0, "raw_score": 1.0, "equivalents": [geno] if geno else [], "copy_number": cn,
"alleles": [], "variant_change": None, "flags": [], "variant_no_calls": [],
"alleles_impacted_by_no_calls": []} # fmt: skip
gj = {"gene": gene, "call_type": "copy_number", "genotype": geno, "feasible_solutions": [geno] if geno else [],
"candidates": [cand] if geno else [], "copy_number": cn, "flags": flags, "variant_no_calls": [],
"variants_not_covered": [], "unexplained_variants": [], "alleles_tested": ["*0", "*1"]} # fmt: skip
row = {"sample_id": sid, "sample_name": sample_name, "solution_id": 1, "gene": gene, "call_type": "copy_number",
"solution": geno, "array_equivalent_alleles_1": "", "array_equivalent_alleles_2": "", "no_call_variants": "",
"alleles_impacted_by_no_call": "", "background_alleles": "", "variant_change": "", "score": "1.0000",
"raw_score": "1.0000", "copy_number_solution": f"{gene}={'' if cn is None else cn}",
"flags": ";".join([*flags, "feasible"])} # fmt: skip
return gj, row
def _sol_json(s: Solution) -> dict[str, Any]:
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
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))
+18 -5
View File
@@ -36,6 +36,7 @@ from simurg.qc.controls import (
ControlBead, ControlBead,
ControlQC, ControlQC,
beads_from_definitions, beads_from_definitions,
canonical_id,
compare_definitions, compare_definitions,
compute_control_metrics, compute_control_metrics,
control_intensities, control_intensities,
@@ -123,9 +124,10 @@ def control_sample(
beads: Sequence[ControlBead], beads: Sequence[ControlBead],
bg_offset: float, bg_offset: float,
sheet_values: Mapping[str, str] | None = None, sheet_values: Mapping[str, str] | None = None,
array_type: str = "genotyping",
) -> ControlSample: ) -> ControlSample:
g, r = control_intensities(grn, red, beads) g, r = control_intensities(grn, red, beads)
qc = compute_control_metrics(beads, g, r, "genotyping", bg_offset) qc = compute_control_metrics(beads, g, r, array_type, bg_offset)
return ControlSample(sample_id, idat_metadata(grn, red, sheet_values), g, r, qc) return ControlSample(sample_id, idat_metadata(grn, red, sheet_values), g, r, qc)
@@ -250,8 +252,8 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
rm = RunManifest(command="qc controls", argv=opts.argv) rm = RunManifest(command="qc controls", argv=opts.argv)
rep.run_id = rm.run_id rep.run_id = rm.run_id
if opts.array_type == "methylation": if opts.array_type == "methylation" and opts.csv is None:
raise SimurgError("E_QC_METHYLATION", "methylation control metrics are planned for v2 (QC-006)") raise SimurgError("E_NO_INPUT", "methylation control QC needs the methylation CSV manifest (--csv)")
if opts.bpm is None and opts.csv is None: if opts.bpm is None and opts.csv is None:
raise SimurgError("E_NO_INPUT", "give --bpm or --csv for the control definitions (QC-001)") raise SimurgError("E_NO_INPUT", "give --bpm or --csv for the control definitions (QC-001)")
if opts.bg_offset < 0: if opts.bg_offset < 0:
@@ -261,6 +263,13 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
names = (COMPAT_RAW, COMPAT_QC) if opts.compat else (CONTROLS_RAW, CONTROLS_QC) names = (COMPAT_RAW, COMPAT_QC) if opts.compat else (CONTROLS_RAW, CONTROLS_QC)
targets = [opts.out / n for n in names] targets = [opts.out / n for n in names]
check_outputs_absent(targets, force=opts.force) # QC-014: other files in the folder are fine check_outputs_absent(targets, force=opts.force) # QC-014: other files in the folder are fine
if opts.array_type == "methylation":
from simurg.formats.methyl_manifest import read_methyl_manifest
assert opts.csv is not None
mman = read_methyl_manifest(opts.csv)
beads = [ControlBead(c.type, c.color_channel, c.name, c.address, canonical_id(c.name)) for c in mman.controls]
else:
manifest = read_bpm(opts.bpm) if opts.bpm else None manifest = read_bpm(opts.bpm) if opts.bpm else None
beads = load_control_beads(manifest, opts.csv, rep) beads = load_control_beads(manifest, opts.csv, rep)
if not beads: if not beads:
@@ -273,7 +282,9 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
assert s.pair.grn is not None and s.pair.red is not None assert s.pair.grn is not None and s.pair.red is not None
grn, red = read_idat(s.pair.grn), read_idat(s.pair.red) grn, red = read_idat(s.pair.grn), read_idat(s.pair.red)
check_pair(grn, red) check_pair(grn, red)
samples.append(control_sample(s.sample_id, grn, red, beads, opts.bg_offset, sheet_values(s, sset.sheet))) samples.append(
control_sample(s.sample_id, grn, red, beads, opts.bg_offset, sheet_values(s, sset.sheet), opts.array_type)
)
# QC-014: sample IDs of an existing summary must match # QC-014: sample IDs of an existing summary must match
summary = opts.out / "sample_summary.tsv" summary = opts.out / "sample_summary.tsv"
if summary.exists(): if summary.exists():
@@ -282,7 +293,9 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
if ids ^ mine: if ids ^ mine:
rep.warn("W_QC_UNMATCHED_SAMPLES", f"{len(ids ^ mine)} sample ID(s) differ from {summary}") rep.warn("W_QC_UNMATCHED_SAMPLES", f"{len(ids ^ mine)} sample ID(s) differ from {summary}")
opts.out.mkdir(parents=True, exist_ok=True) opts.out.mkdir(parents=True, exist_ok=True)
written = write_control_tables(opts.out, samples, beads, opts.bg_offset, rep, compat=opts.compat) written = write_control_tables(
opts.out, samples, beads, opts.bg_offset, rep, array_type=opts.array_type, compat=opts.compat
)
for p in written: for p in written:
rm.add_output(p, "simurg.controls/1") rm.add_output(p, "simurg.controls/1")
rm.summary = {"samples": len(samples)} rm.summary = {"samples": len(samples)}
-2
View File
@@ -593,8 +593,6 @@ def run_qc_report(opts: QcReportOptions, rep: Reporter) -> int:
"Genotyping and methylation datasets cannot be mixed: " "Genotyping and methylation datasets cannot be mixed: "
+ "; ".join(f"{t}: {', '.join(v)}" for t, v in by_type.items()), + "; ".join(f"{t}: {', '.join(v)}" for t, v in by_type.items()),
) )
if "methylation" in types:
raise SimurgError("E_QC_METHYLATION", "methylation datasets in the report arrive with v2 (QC-062)")
total = sum(len(ds.samples) for ds in datasets) total = sum(len(ds.samples) for ds in datasets)
if total > 20000: if total > 20000:
rep.warn("W_QC_TOO_FEW", f"{total} samples in total: the report may be slow to open (QC-057)") rep.warn("W_QC_TOO_FEW", f"{total} samples in total: the report may be slow to open (QC-057)")
+150
View File
@@ -0,0 +1,150 @@
"""``simurg train`` — ArrayTrain: an EGT cluster file from a cohort's GTCs (plan §12). I/O and orchestration; the
numerics live in ``simurg.train.clusters``.
Normalised intensities come from each GTC's raw X/Y and normalisation transforms (independent of the cluster file the
GTC was called with). Sex-aware training: X loci (outside the PAR) from female samples, Y loci from males (haploid),
MT haploid, PAR and autosomes from everyone; the sex is the GTC's ``gender`` field. Intensity-only loci get a single
R cluster.
"""
from __future__ import annotations
import datetime as _dt
import json
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import numpy.typing as npt
from simurg import __version__
from simurg.core.errors import ExitCode, SimurgError
from simurg.core.log import Reporter
BLOCK = 2000
@dataclass
class TrainOptions:
gtcs: Path
bpm: Path
out: Path
min_samples: int = 100
max_samples: int = 2000
force: bool = False
argv: list[str] = field(default_factory=list)
def _load(paths: list[Path], lookup: npt.NDArray[np.int32], rep: Reporter) -> tuple[np.ndarray, np.ndarray, list[str]]:
from simurg.formats.gtc import read_gtc
from simurg.signal.normalization.apply import normalize, polar
n = len(lookup)
theta = np.full((len(paths), n), np.nan, dtype=np.float32)
r = np.full((len(paths), n), np.nan, dtype=np.float32)
sexes = []
for i, p in enumerate(paths):
g = read_gtc(p)
if len(g.raw_x) != n:
raise SimurgError("E_EGT_MANIFEST_MISMATCH", f"{p} has {len(g.raw_x)} loci, the manifest {n}")
nx, ny = normalize(g.raw_x, g.raw_y, lookup, g.normalization_transforms)
t, rr = polar(nx, ny)
zero = (g.raw_x == 0) & (g.raw_y == 0)
t[zero] = np.nan
rr[zero] = np.nan
theta[i], r[i] = t, rr
sexes.append(g.gender.decode(errors="replace")[:1].upper() if g.gender else "U")
if (i + 1) % 50 == 0:
rep.info(f"loaded {i + 1}/{len(paths)} GTC(s)")
return theta, r, sexes
def run_train(o: TrainOptions, rep: Reporter) -> int:
"""``simurg train`` (plan §12)."""
from simurg.formats.bpm import read_bpm
from simurg.formats.discovery import discover_gtcs
from simurg.formats.egt import write_egt
from simurg.genotype.sex import chromosome_classes
from simurg.train.clusters import BlockFit, fit_block, impute_missing, learn_relations
if o.out.exists() and not o.force:
raise SimurgError("E_OUTPUT_EXISTS", f"{o.out} exists", hint="give --force to overwrite")
import re
if not re.fullmatch(r"[A-Za-z0-9_-]+", o.out.stem):
rep.warn("W_EGT_NAME", f"{o.out.name}: characters outside [A-Za-z0-9_-] may trouble other tools (IN-045)")
man = read_bpm(o.bpm)
paths = sorted(discover_gtcs(o.gtcs).values())
if len(paths) > o.max_samples:
idx = np.random.default_rng(42).choice(len(paths), o.max_samples, replace=False)
paths = [paths[i] for i in sorted(idx)]
if len(paths) < o.min_samples:
raise SimurgError("E_TRAIN_TOO_SMALL", f"{len(paths)} GTC(s); training needs at least {o.min_samples}",
hint="lower --min-samples only for research use") # fmt: skip
theta, r, sexes = _load(paths, man.normalization_lookups(), rep)
sx = np.array(sexes)
is_x, is_y, _auto = chromosome_classes(list(man.chroms))
core = np.array([c.upper().removeprefix("CHR") for c in man.chroms])
is_mt = np.isin(core, ["MT", "M"])
females, males = sx == "F", sx == "M"
rep.info(f"training on {len(paths)} GTC(s): {int(females.sum())} F, {int(males.sum())} M, "
f"{int((~females & ~males).sum())} U") # fmt: skip
nl = man.num_loci
haploid = is_y | is_mt
use_rows = {"all": np.ones(len(paths), bool), "F": females, "M": males}
parts: list[BlockFit] = []
for s in range(0, nl, BLOCK):
e = min(nl, s + BLOCK)
t, rr = theta[:, s:e].T, r[:, s:e].T
use = np.ones_like(t, dtype=bool)
use[is_x[s:e]] = use_rows["F"][None, :] if females.sum() >= 10 else True
use[is_y[s:e]] = use_rows["M"][None, :]
parts.append(fit_block(t, rr, use, haploid[s:e]))
if (s // BLOCK) % 50 == 0:
rep.info(f"clustered {e}/{nl} loci")
fit = BlockFit(*(np.concatenate([getattr(p, f) for p in parts]) for f in BlockFit.__dataclass_fields__))
# missing clusters, per assay type (Infinium II vs I)
for inf1 in (False, True):
sel = (man.assay_types > 0) == inf1
if not sel.any():
continue
sub = BlockFit(*(getattr(fit, f)[sel] for f in BlockFit.__dataclass_fields__))
rel = learn_relations(sub)
impute_missing(sub, rel)
for f in BlockFit.__dataclass_fields__:
getattr(fit, f)[sel] = getattr(sub, f)
rep.info(f"Infinium {'I' if inf1 else 'II'}: θ AA→AB {rel.d_aa:.3f}, AB→BB {rel.d_bb:.3f}, "
f"R AA/AB {rel.r_aa:.2f}, BB/AB {rel.r_bb:.2f}") # fmt: skip
# intensity-only loci: one R cluster
io = man.intensity_only
if io.any():
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
med_r = np.nanmedian(r[:, io], axis=0)
sd_r = np.nanstd(r[:, io], axis=0)
nv = np.isfinite(r[:, io]).sum(axis=0)
fit.n[io] = np.stack([nv, np.zeros_like(nv), np.zeros_like(nv)], axis=1)
fit.theta_mean[io] = np.array([0.0, 0.5, 1.0], np.float32)
fit.theta_dev[io] = 0.05
fit.r_mean[io] = np.nan_to_num(med_r, nan=1.0)[:, None]
fit.r_dev[io] = np.nan_to_num(sd_r, nan=0.1)[:, None]
fit.train_score[io] = 0.0
fit.cluster_sep[io] = 0.0
o.out.parent.mkdir(parents=True, exist_ok=True)
now = _dt.datetime.now()
write_egt(o.out, list(man.names), man.address_a, fit.n, fit.theta_mean, fit.theta_dev, fit.r_mean, fit.r_dev,
fit.cluster_sep, fit.train_score, man.manifest_name, cluster_version=f"SimurgArray {__version__}",
date_created=f"{now.month}/{now.day}/{now.year} {now:%I:%M:%S %p}") # fmt: skip
ncl = (fit.n > 0).sum(axis=1)[~io]
stats = {"samples": len(paths), "loci": nl, "intensity_only": int(io.sum()),
"clusters_3": int((ncl == 3).sum()), "clusters_2": int((ncl == 2).sum()),
"clusters_1": int((ncl == 1).sum()), "clusters_0": int((ncl == 0).sum()),
"median_train_score": float(np.median(fit.train_score[~io])),
"median_cluster_sep": float(np.median(fit.cluster_sep[~io]))} # fmt: skip
Path(str(o.out) + ".json").write_text(json.dumps({"software": f"SimurgArray {__version__}",
"manifest": man.manifest_name, **stats}, indent=1)) # fmt: skip
rep.info("ArrayTrain: " + ", ".join(f"{k} {v:.3f}" if isinstance(v, float) else f"{k} {v}"
for k, v in stats.items()) + f" → {o.out}") # fmt: skip
return ExitCode.OK
+14
View File
@@ -51,6 +51,20 @@ canonical:
bs conversion i u3: bs1_U3 bs conversion i u3: bs1_U3
bs conversion i u4: bs1_U4 bs conversion i u4: bs1_U4
bs conversion i u5: bs1_U5 bs conversion i u5: bs1_U5
bs conversion i-c1: bs1_C1
bs conversion i-c2: bs1_C2
bs conversion i-c3: bs1_C3
bs conversion i-c4: bs1_C4
bs conversion i-c5: bs1_C5
bs conversion i-c6: bs1_C6
bs conversion i-u1: bs1_U1
bs conversion i-u2: bs1_U2
bs conversion i-u3: bs1_U3
bs conversion i-u4: bs1_U4
bs conversion i-u5: bs1_U5
bs conversion i-u6: bs1_U6
bs conversion i c6: bs1_C6
bs conversion i u6: bs1_U6
bs conversion ii-1: bs2_1 bs conversion ii-1: bs2_1
bs conversion ii-2: bs2_2 bs conversion ii-2: bs2_2
bs conversion ii-3: bs2_3 bs conversion ii-3: bs2_3
+18 -4
View File
@@ -71,6 +71,10 @@ METRICS: tuple[Metric, ...] = (
Metric("bs_conv_2", "bisulfiteConversionII", False, True), Metric("bs_conv_2", "bisulfiteConversionII", False, True),
Metric("bs_conv_2_bg", "bisulfiteConversionIIBg", False, True), Metric("bs_conv_2_bg", "bisulfiteConversionIIBg", False, True),
) )
GTMM_PM = [f"gtmm_{i}_pm" for i in range(1, 7)]
GTMM_MM = [f"gtmm_{i}_mm" for i in range(1, 7)]
SPEC2 = ["spec2_1", "spec2_2", "spec2_3"]
BS2 = ["bs2_1", "bs2_2", "bs2_3", "bs2_4"]
ASSAY_CONTROL_COLUMN = "assay_control_norm_r" # QC-005: copied from the sample summary ASSAY_CONTROL_COLUMN = "assay_control_norm_r" # QC-005: copied from the sample summary
COMPAT_METRIC_NAMES = { COMPAT_METRIC_NAMES = {
"restoration": "RestorationQC", "restoration": "RestorationQC",
@@ -223,14 +227,12 @@ def compute_control_metrics(
red: npt.NDArray[np.float64], red: npt.NDArray[np.float64],
array_type: str = "genotyping", array_type: str = "genotyping",
bg_offset: float = DEFAULT_BG_OFFSET, bg_offset: float = DEFAULT_BG_OFFSET,
bs_conv_red_bg_set: str = "U3-U5",
) -> ControlQC: ) -> ControlQC:
"""QC-003/QC-004 metrics of one sample. Single-probe values are bead means; min/max run over all beads (QC-002).""" """QC-003/QC-004 metrics of one sample. Single-probe values are bead means; min/max run over all beads (QC-002)."""
if array_type != "genotyping":
from simurg.core.errors import SimurgError
raise SimurgError("E_QC_METHYLATION", "methylation control metrics are planned for v2 (QC-006)")
p = _Probes(beads, green, red) p = _Probes(beads, green, red)
x = bg_offset x = bg_offset
bs_red_u = ["bs1_U3", "bs1_U4", "bs1_U5"] if bs_conv_red_bg_set == "U3-U5" else ["bs1_U4", "bs1_U5", "bs1_U6"]
formulas: dict[str, tuple[Any, Any]] = { formulas: dict[str, tuple[Any, Any]] = {
# column: (numerator thunk, denominator thunk); each thunk records the probes it needs # column: (numerator thunk, denominator thunk); each thunk records the probes it needs
"restoration": (lambda: p.mean("restoration", "G"), lambda: p.max(["ext_A", "ext_T"], "G") + x), "restoration": (lambda: p.mean("restoration", "G"), lambda: p.max(["ext_A", "ext_T"], "G") + x),
@@ -246,6 +248,18 @@ def compute_control_metrics(
"nsb_red": (lambda: p.max(["ext_C", "ext_G"], "R") + x, lambda: p.max(["nsb_bkg"], "R")), "nsb_red": (lambda: p.max(["ext_C", "ext_G"], "R") + x, lambda: p.max(["nsb_bkg"], "R")),
"np_green": (lambda: p.min(["np_C", "np_G"], "G"), lambda: p.max(["np_A", "np_T"], "G")), "np_green": (lambda: p.min(["np_C", "np_G"], "G"), lambda: p.max(["np_A", "np_T"], "G")),
"np_red": (lambda: p.min(["np_A", "np_T"], "R"), lambda: p.max(["np_C", "np_G"], "R")), "np_red": (lambda: p.min(["np_A", "np_T"], "R"), lambda: p.max(["np_C", "np_G"], "R")),
# methylation (QC-004 rows 9, 15-24; MET-006)
"target_removal_2": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.mean("target_removal_2", "G")),
"specificity_1_green": (lambda: p.min(GTMM_PM[:3], "G"), lambda: p.max(GTMM_MM[:3], "G")),
"specificity_1_red": (lambda: p.min(GTMM_PM[3:], "R"), lambda: p.max(GTMM_MM[3:], "R")),
"specificity_2": (lambda: p.min(SPEC2, "R"), lambda: p.max(SPEC2, "G")),
"specificity_2_bg": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.max(SPEC2, "G")),
"bs_conv_1_green": (lambda: p.min(["bs1_C1", "bs1_C2"], "G"), lambda: p.max(["bs1_U1", "bs1_U2"], "G")),
"bs_conv_1_green_bg": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.max(["bs1_U1", "bs1_U2"], "G")),
"bs_conv_1_red": (lambda: p.min(["bs1_C3", "bs1_C4", "bs1_C5"], "R"), lambda: p.max(bs_red_u, "R")),
"bs_conv_1_red_bg": (lambda: p.max(["ext_C", "ext_G"], "R") + x, lambda: p.max(bs_red_u, "R")),
"bs_conv_2": (lambda: p.min(BS2, "R"), lambda: p.max(BS2, "G")),
"bs_conv_2_bg": (lambda: p.max(["ext_A", "ext_T"], "G") + x, lambda: p.max(BS2, "G")),
} }
res = ControlQC(values={}) res = ControlQC(values={})
for m in metrics_for(array_type): for m in metrics_for(array_type):
+290
View File
@@ -0,0 +1,290 @@
"""Methylation signal processing — spec MET-005, MET-010 … MET-012 (plan §7, v2). Pure functions, no I/O.
* Raw signals (MET-005): Infinium I — M = channel[AddressB], U = channel[AddressA] in the probe's colour channel,
out-of-band = the other channel; Infinium II — M = Grn[AddressA], U = Red[AddressA]. Addresses missing from the
IDAT or read from < 3 beads are masked.
* Detection p-value, pOOBAH [LIT: Zhou, Laird & Shen 2017 NAR 45:e22; OSS: sesame `pOOBAH`, MIT]: empirical CDF of
the out-of-band Infinium I signal plus the negative controls per channel; p = 1 − max(F(M), F(U)) with each signal
read in its own channel (Infinium II: M green, U red).
* Background correction, noob [LIT: Triche et al. 2013 NAR 41:e90; OSS: sesame `noob`, MIT; limma `normexp.signal`,
GPL — formula only]: normal-exponential convolution per channel, background = out-of-band + negative controls,
µ and σ from Huber's M-estimator (MASS `huber`, k = 1.5, MAD scale), α = max(Huber(in-band) − µ, 10); offset 15.
* Dye bias (MET-010 step 3) [OURS]: linear — scale red and green so the means of the normalization controls
(NORM_A/T red, NORM_C/G green) meet at their average; nonlinear — quantile matching of the Infinium I red and green
in-band signals to their average quantile function.
* β = M / (M + U + α) with signals floored at 1; M-value = log2(β' / (1 − β')), β' clipped to [1e-6, 1 − 1e-6].
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
from simurg.formats.idat import Idat
from simurg.formats.methyl_manifest import MethylManifest
from simurg.signal.raw_xy import AddressIndex
F64 = npt.NDArray[np.float64]
Mask = npt.NDArray[np.bool_]
MIN_BEADS = 3
NOOB_OFFSET = 15.0
@dataclass
class MethylSignals:
"""In-band M/U per probe (float64, NaN where masked), out-of-band signal of Infinium I probes, control
intensities (manifest control order) and the probe layout needed to split them by channel."""
m: F64
u: F64
oob_m: F64 # Infinium I: the other channel at the M address; NaN for Infinium II
oob_u: F64
masked: Mask
col: npt.NDArray[np.str_] # "R" / "G" / "" (Infinium II)
ctrl_green: F64
ctrl_red: F64
ctrl_type: list[str]
@property
def type_i(self) -> Mask:
return np.asarray(self.col != "", dtype=np.bool_)
def assemble(grn: Idat, red: Idat, man: MethylManifest, *, min_beads: int = MIN_BEADS) -> MethylSignals:
"""MET-005 raw signal assembly."""
gi, ri = AddressIndex(grn), AddressIndex(red)
def read(addrs: npt.NDArray[np.int64]) -> tuple[F64, F64, Mask]:
rg, fg = gi.lookup(addrs)
rr, fr = ri.lookup(addrs)
g = grn.mean[rg].astype(np.float64)
r = red.mean[rr].astype(np.float64)
ok = fg & fr & (grn.n_beads[rg] >= min_beads) & (red.n_beads[rr] >= min_beads) & (addrs != 0)
return g, r, ok
ga, ra, oka = read(man.u)
gb, rb, okb = read(np.where(man.m != 0, man.m, man.u))
t1 = man.m != 0
is_r = man.col == "R"
# Infinium II: M green, U red at address A; Infinium I: both addresses in the probe's channel
m = np.where(t1, np.where(is_r, rb, gb), ga)
u = np.where(t1, np.where(is_r, ra, ga), ra)
oob_m = np.where(t1, np.where(is_r, gb, rb), np.nan)
oob_u = np.where(t1, np.where(is_r, ga, ra), np.nan)
masked = ~(oka & np.where(t1, okb, True)) | (t1 & ~np.isin(man.col, ("R", "G")))
for a in (m, u, oob_m, oob_u):
a[masked] = np.nan
caddr = np.array([c.address for c in man.controls], dtype=np.int64)
cg, cr, cok = read(caddr)
cg[~cok] = np.nan
cr[~cok] = np.nan
return MethylSignals(m, u, oob_m, oob_u, masked, man.col, cg, cr, [c.type for c in man.controls])
def _finite(a: F64) -> F64:
return a[np.isfinite(a)]
def background(s: MethylSignals, combine_neg: bool = True) -> tuple[F64, F64]:
"""Out-of-band background per channel (green, red): Infinium I OOB signal (+ negative controls)."""
neg = np.array([combine_neg and t.casefold() == "negative" for t in s.ctrl_type], dtype=np.bool_)
red_i, grn_i = s.col == "R", s.col == "G"
oob_g = np.concatenate([s.oob_m[red_i], s.oob_u[red_i], s.ctrl_green[neg]])
oob_r = np.concatenate([s.oob_m[grn_i], s.oob_u[grn_i], s.ctrl_red[neg]])
return _finite(oob_g), _finite(oob_r)
def in_band(s: MethylSignals) -> tuple[F64, F64]:
"""In-band foreground per channel (green, red): Infinium I M/U in their channel, Infinium II M green / U red."""
red_i, grn_i, t2 = s.col == "R", s.col == "G", s.col == ""
g = np.concatenate([s.m[grn_i], s.u[grn_i], s.m[t2]])
r = np.concatenate([s.m[red_i], s.u[red_i], s.u[t2]])
return _finite(g), _finite(r)
# ------------------------------------------------------------------------------------------------ detection
def _ecdf(ref: F64): # type: ignore[no-untyped-def]
srt = np.sort(ref)
n = len(srt)
def f(x: F64) -> F64:
if n == 0:
return np.zeros(len(x))
return np.searchsorted(srt, x, side="right") / n # R ecdf: P(X <= x)
return f
def detection_p(s: MethylSignals, combine_neg: bool = True) -> F64:
"""pOOBAH detection p-value per probe; 1 where masked."""
bg_g, bg_r = background(s, combine_neg)
fg, fr = _ecdf(bg_g), _ecdf(bg_r)
red_i, t2 = s.col == "R", s.col == ""
m, u = np.nan_to_num(s.m, nan=0.0), np.nan_to_num(s.u, nan=0.0)
fm = np.where(red_i, fr(m), fg(m)) # Infinium II M is green, Infinium I green M is green
fu = np.where(red_i | t2, fr(u), fg(u)) # Infinium II U is red
p = 1.0 - np.maximum(fm, fu)
p[s.masked] = 1.0
return np.asarray(p, dtype=np.float64)
# ------------------------------------------------------------------------------------------------ noob
def huber(y: F64, k: float = 1.5, tol: float = 1e-6) -> tuple[float, float]:
"""MASS::huber: Huber M-estimator of location with MAD scale (returns the location before the last update)."""
y = _finite(np.asarray(y, dtype=np.float64))
n = len(y)
if n == 0:
raise ValueError("huber: no finite values")
mu = float(np.median(y))
s = float(1.4826 * np.median(np.abs(y - mu)))
if s == 0:
raise ValueError("huber: cannot estimate scale, MAD is zero")
for _ in range(1000):
yy = np.minimum(np.maximum(mu - k * s, y), mu + k * s)
mu1 = float(yy.sum() / n)
if abs(mu - mu1) < tol * s:
break
mu = mu1
return mu, s
_erfc = np.frompyfunc(math.erfc, 1, 1)
def _mills(z: F64) -> F64:
"""φ(z)/Φ(z) (inverse Mills ratio), stable far into the lower tail."""
z = np.asarray(z, dtype=np.float64)
out = np.empty_like(z)
lo = z < -30.0
zz = z[~lo]
phi = np.exp(-0.5 * zz * zz) / math.sqrt(2.0 * math.pi)
big_phi = 0.5 * np.asarray(_erfc(-zz / math.sqrt(2.0)), dtype=np.float64)
out[~lo] = phi / big_phi
zl = z[lo]
r = 1.0 / (zl * zl)
out[lo] = -zl / (1.0 - r + 3.0 * r * r - 15.0 * r**3 + 105.0 * r**4)
return out
def normexp_signal(mu: float, sigma: float, alpha: float, x: F64) -> F64:
"""limma ``normexp.signal`` (formula): E[signal | observed x] under x = N(µ, σ²) + Exp(α)."""
if alpha <= 0 or sigma <= 0:
raise ValueError("normexp: alpha and sigma must be positive")
mu_sf = x - mu - sigma * sigma / alpha
sig = mu_sf + sigma * _mills(mu_sf / sigma)
ok = np.isfinite(sig)
if (sig[ok] < 0).any():
sig[ok] = np.maximum(sig[ok], 1e-6)
return sig
@dataclass
class NoobFit:
mu: float
sigma: float
alpha: float
def noob_fit(ib: F64, oob: F64) -> NoobFit:
ib = np.where(ib < 1, 1.0, ib)
mu, s = huber(oob)
return NoobFit(mu, s, max(huber(ib)[0] - mu, 10.0))
def noob(
s: MethylSignals, offset: float = NOOB_OFFSET, combine_neg: bool = True
) -> tuple[MethylSignals, NoobFit, NoobFit]:
"""Background-corrected copy of ``s`` (probe and control signals); returns the green and red fits."""
bg_g, bg_r = background(s, combine_neg)
ib_g, ib_r = in_band(s)
fit_g, fit_r = noob_fit(ib_g, bg_g), noob_fit(ib_r, bg_r)
def corr(x: F64, f: NoobFit) -> F64:
y = np.where(np.isfinite(x), np.maximum(x, 1.0), np.nan)
out = np.full_like(y, np.nan)
ok = np.isfinite(y)
out[ok] = normexp_signal(f.mu, f.sigma, f.alpha, y[ok]) + offset
return out
red_i, t2 = s.col == "R", s.col == ""
fm = np.where(red_i, 1, 0) # channel of M: red for Infinium I red, else green
fu = np.where(red_i | t2, 1, 0)
m = np.where(fm == 1, corr(s.m, fit_r), corr(s.m, fit_g))
u = np.where(fu == 1, corr(s.u, fit_r), corr(s.u, fit_g))
out = MethylSignals(
m, u, s.oob_m, s.oob_u, s.masked, s.col, corr(s.ctrl_green, fit_g), corr(s.ctrl_red, fit_r), s.ctrl_type
)
return out, fit_g, fit_r
# ------------------------------------------------------------------------------------------------ dye bias
def _scale(s: MethylSignals, fg: float, fr: float) -> MethylSignals:
red_i, t2 = s.col == "R", s.col == ""
m = s.m * np.where(red_i, fr, fg)
u = s.u * np.where(red_i | t2, fr, fg)
return MethylSignals(m, u, s.oob_m, s.oob_u, s.masked, s.col, s.ctrl_green * fg, s.ctrl_red * fr, s.ctrl_type)
def dye_bias_linear(s: MethylSignals) -> MethylSignals:
"""Scale the channels so the normalization-control means (NORM_A/T red, NORM_C/G green) meet at their average."""
t = np.array([x.upper() for x in s.ctrl_type])
red = _finite(s.ctrl_red[np.isin(t, ("NORM_A", "NORM_T"))])
grn = _finite(s.ctrl_green[np.isin(t, ("NORM_C", "NORM_G"))])
if not len(red) or not len(grn):
raise ValueError("dye bias: normalization controls missing")
mr, mg = float(red.mean()), float(grn.mean())
ref = (mr + mg) / 2.0
return _scale(s, ref / mg, ref / mr)
def dye_bias_nonlinear(s: MethylSignals) -> MethylSignals:
"""Quantile-match the Infinium I red and green in-band signals to their average quantile function; every signal
of a channel is mapped through that channel's curve (linear extrapolation beyond the Infinium I range)."""
red_i, grn_i, t2 = s.col == "R", s.col == "G", s.col == ""
r_ref = np.sort(_finite(np.concatenate([s.m[red_i], s.u[red_i]])))
g_ref = np.sort(_finite(np.concatenate([s.m[grn_i], s.u[grn_i]])))
if len(r_ref) < 2 or len(g_ref) < 2:
raise ValueError("dye bias: too few Infinium I probes")
q = np.linspace(0, 1, 1001)
rq, gq = np.quantile(r_ref, q), np.quantile(g_ref, q)
target = (rq + gq) / 2.0
def mapper(src_q: F64): # type: ignore[no-untyped-def]
lo_slope = target[0] / src_q[0] if src_q[0] > 0 else 1.0
hi_slope = target[-1] / src_q[-1] if src_q[-1] > 0 else 1.0
def f(x: F64) -> F64:
y = np.interp(x, src_q, target)
y = np.where(x < src_q[0], x * lo_slope, y)
return np.where(x > src_q[-1], x * hi_slope, y)
return f
fr, fg = mapper(rq), mapper(gq)
m = np.where(red_i, fr(s.m), fg(s.m))
u = np.where(red_i | t2, fr(s.u), fg(s.u))
return MethylSignals(m, u, s.oob_m, s.oob_u, s.masked, s.col, fg(s.ctrl_green), fr(s.ctrl_red), s.ctrl_type)
# ------------------------------------------------------------------------------------------------ beta
def beta_values(s: MethylSignals, offset: float = 0.0) -> F64:
m = np.maximum(s.m, 1.0)
u = np.maximum(s.u, 1.0)
return m / (m + u + offset)
def m_values(beta: F64) -> F64:
b = np.clip(beta, 1e-6, 1 - 1e-6)
return np.log2(b / (1 - b))
def average_signals(s: MethylSignals, analytical: Mask) -> tuple[float, float]:
"""MET-012: mean green and red in-band signal over analytical probes."""
red_i, grn_i, t2 = s.col == "R", s.col == "G", s.col == ""
a = analytical
g = _finite(np.concatenate([s.m[grn_i & a], s.u[grn_i & a], s.m[t2 & a]]))
r = _finite(np.concatenate([s.m[red_i & a], s.u[red_i & a], s.u[t2 & a]]))
return (float(g.mean()) if len(g) else math.nan, float(r.mean()) if len(r) else math.nan)
+1
View File
@@ -0,0 +1 @@
"""ArrayTrain: cluster-file training (plan §12)."""
+342
View File
@@ -0,0 +1,342 @@
"""ArrayTrain: per-locus genotype cluster training in (θ, R) — plan §12. Pure functions, no I/O.
Explicitly **not** a reproduction of the vendor's clustering [OURS; LIT: Teo et al. 2007 Bioinformatics 23:2741
(Illuminus: mixture models in (θ, R)); Carvalho et al. 2007 Biostatistics 8:485 (CRLMM); Illumina GenTrain 2/3
technical notes (cluster record fields)]:
1. **Mixtures per locus**, vectorised over a block of loci: unbounded 1-D Gaussian mixtures on θ with 1, 2 and 3
components plus a small uniform outlier component, each fitted by EM from several starts (canonical positions and
θ quantiles; SD floor ``EM_SD_MIN`` keeps components off the θ = 0 / 1 spikes). The number of components is
chosen by BIC among admissible fits (every component ≥ max(3, 1 % of the samples) hard-assigned samples and
adjacent components ≥ ``MIN_SEP`` standardised gaps apart). No fixed θ windows: compressed or asymmetric loci
(e.g. AA 0.05 / AB 0.27 / BB 0.45) are common. Robustness comes from the outlier component (the plan's Student-t
mixture is replaced by it; documented). Haploid loci (male X, Y, MT) take at most two components.
2. **Labelling**: the θ-ordered components map onto AA / AB / BB by the best non-decreasing labelling under the
HWE goodness of fit of the implied genotype counts (with an inbreeding allowance for population structure), weak
priors on each cluster's position (homozygotes towards the ends) and on the θ gaps between genotype groups; two
poorly separated adjacent components may share a genotype (a split cluster). This is what separates AA + AB
from AB + BB at two-cluster loci [OURS; HWE-guided labelling as in LIT: Teo et al. 2007].
3. **Missing clusters** are imputed from relationships learned over well-clustered loci of the same assay type
(Infinium I / II): least-squares lines predicting a cluster's θ from its neighbour's (θ_AB from θ_AA, …), median
R ratios R_AA/R_AB and R_BB/R_AB, median SDs — the empirical replacement for the vendor's neural network. A lone
cluster is homozygous (AA or BB by its θ).
4. **Scores**: ``cluster_sep`` = the smallest standardised θ gap between adjacent present clusters,
gap / (sd₁ + sd₂), squashed to 0–1 by (gap_std − 1) / 3; ``train_score`` (the EGT GenTrain field, which scales
every call's GenCall score) = mean posterior of the assigned cluster × (0.6 + 0.4 × cluster_sep) × fraction of
samples with intensity, capped at 0.9 [OURS].
"""
from __future__ import annotations
import itertools
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
F32 = npt.NDArray[np.float32]
F64 = npt.NDArray[np.float64]
SD_BOUNDS = (0.008, 0.15)
OUTLIER_W = 0.02
EM_ITERS = 30
MIN_FRAC = 0.01
MIN_N = 3
EM_SD_MIN = 0.02 # SD floor inside the EM: keeps components off the θ = 0 / 1 spikes of saturated channels
MIN_SEP = 1.5 # standardised θ gap |Δμ| / (sd₁ + sd₂) below which two components are one cluster
HWE_F = 0.1 # inbreeding allowance in the labelling likelihood (population structure)
GAP_SD = 0.12 # spread of the expected cluster θ gaps in the labelling prior
GAP_AA, GAP_BB = 0.42, 0.42 # expected θ_AB − θ_AA and θ_BB − θ_AB
POS_MEAN = (0.03, 0.5, 0.97) # weak prior on where AA / AB / BB sit (homozygotes at the ends)
POS_SD = (0.2, 0.25, 0.2)
MERGE_SEP = 3.5 # adjacent components closer than this (standardised) may be one genotype cluster
SCORE_CAP = 0.9
@dataclass
class BlockFit:
"""Per-locus cluster parameters for a block of L loci; (L, 3) arrays are (AA, AB, BB)."""
n: npt.NDArray[np.int32]
theta_mean: F32
theta_dev: F32
r_mean: F32
r_dev: F32
cluster_sep: F32
train_score: F32
conf: F32 # mean max posterior
valid_frac: F32
def _em(tz: F64, ok: npt.NDArray[np.bool_], m: F64, sd: F64) -> tuple[F64, F64, F64, F64]:
"""Unbounded 1-D Gaussian mixture EM (+ uniform outlier) vectorised over loci; ``m``/``sd`` (L, K) starts.
Returns means, SDs, responsibilities (L, S, K) and the log-likelihood (L,)."""
L, K = m.shape
nv = np.maximum(ok.sum(axis=1), 1).astype(np.float64)
w = np.full((L, K), (1 - OUTLIER_W) / K)
for it in range(EM_ITERS + 1):
dens = w[:, None, :] * np.exp(-0.5 * ((tz[:, :, None] - m[:, None, :]) / sd[:, None, :]) ** 2) / (
sd[:, None, :] * np.sqrt(2 * np.pi)) # fmt: skip
tot = dens.sum(axis=2) + OUTLIER_W # uniform outlier density on [0, 1]
resp = dens / tot[:, :, None]
resp[~ok] = 0.0
if it == EM_ITERS:
break
nk = resp.sum(axis=1)
with np.errstate(invalid="ignore", divide="ignore"):
mk = (resp * tz[:, :, None]).sum(axis=1) / nk
vk = (resp * (tz[:, :, None] - mk[:, None, :]) ** 2).sum(axis=1) / nk
m = np.where(nk > 0.5, np.clip(mk, 0.0, 1.0), m)
sd = np.where(nk > 1.5, np.clip(np.sqrt(np.maximum(vk, 0)), EM_SD_MIN, SD_BOUNDS[1]), sd)
w = nk / nv[:, None] * (1 - OUTLIER_W)
ll = np.where(ok, np.log(tot), 0.0).sum(axis=1)
return m, sd, resp, ll
def _starts(tz: F64, ok: npt.NDArray[np.bool_], k: int) -> list[F64]:
"""EM starts for k components: fixed canonical positions and the samples' θ quantiles."""
L = tz.shape[0]
fixed = {
1: [[0.5]],
2: [[0.1, 0.9], [0.1, 0.5], [0.5, 0.9]],
3: [[0.08, 0.5, 0.92], [0.05, 0.3, 0.6], [0.4, 0.7, 0.95]],
}[k]
out = [np.tile(np.array(f, np.float64), (L, 1)) for f in fixed]
qs = {1: [50], 2: [20, 80], 3: [10, 50, 90]}[k]
with np.errstate(all="ignore"):
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
q = np.nanpercentile(np.where(ok, tz, np.nan), qs, axis=1).T
out.append(np.where(np.isfinite(q), q, 0.5).reshape(L, k))
return out
def _hwe_gof(c: F64) -> F64:
"""HWE goodness of fit of genotype counts (…, 3): log-likelihood under HWE (with inbreeding allowance
``HWE_F``, allele-counting frequency) minus the saturated multinomial log-likelihood; ≤ 0, ≈ 0 when the counts
fit HWE whatever their number of genotype classes."""
nn = np.maximum(c.sum(axis=-1), 1)
p = (2 * c[..., 2] + c[..., 1] + 0.5) / (2 * nn + 1)
q = 1 - p
f = HWE_F
g = np.stack([q * q + f * p * q, 2 * p * q * (1 - f), p * p + f * p * q], axis=-1)
sat = np.where(c > 0, c * np.log(np.maximum(c, 1) / nn[..., None]), 0.0)
return np.asarray((c * np.log(np.maximum(g, 1e-300)) - sat).sum(axis=-1), np.float64)
def _labelings(k: int) -> npt.NDArray[np.int64]:
"""Non-decreasing maps of k θ-ordered components onto genotypes 0 (AA) / 1 (AB) / 2 (BB)."""
return np.array([c for c in itertools.combinations_with_replacement(range(3), k)], np.int64)
def _label(cnt: F64, m: F64, sd: F64, haploid: npt.NDArray[np.bool_]) -> npt.NDArray[np.int64]:
"""Genotype label of each θ-ordered component (L, k) [OURS]: every non-decreasing labelling is scored by the
HWE goodness of fit of the implied genotype counts, a weak Gaussian prior on each component's position
(``POS_MEAN``/``POS_SD``) and on the θ gaps between adjacent genotype groups (``GAP_AA``/``GAP_BB``). Two
adjacent components may share a genotype (a split cluster) only when they are poorly separated
(standardised gap < ``MERGE_SEP``); haploid loci take no AB and no HWE term."""
L, k = m.shape
labs = _labelings(k)
score = np.full((L, len(labs)), -np.inf)
pos_m, pos_s = np.array(POS_MEAN), np.array(POS_SD)
sep = np.diff(m, axis=1) / (sd[:, 1:] + sd[:, :-1]) if k > 1 else np.zeros((L, 0))
for j, lab in enumerate(labs):
gc = np.stack([np.where(lab == g, cnt, 0).sum(axis=1) for g in range(3)], axis=1)
sc = np.where(haploid, 0.0, _hwe_gof(gc))
sc = sc - 0.5 * (((m - pos_m[lab]) / pos_s[lab]) ** 2).sum(axis=1)
with np.errstate(invalid="ignore", divide="ignore"):
gm = np.stack([np.where(lab == g, cnt * m, 0).sum(axis=1) / gc[:, g] for g in range(3)], axis=1)
present = sorted(set(lab.tolist()))
for g1, g2 in itertools.pairwise(present):
expect = {(0, 1): GAP_AA, (1, 2): GAP_BB, (0, 2): GAP_AA + GAP_BB}[(g1, g2)]
sc = sc - 0.5 * ((gm[:, g2] - gm[:, g1] - expect) / GAP_SD) ** 2
ok = np.ones(L, bool)
for i in range(k - 1):
if lab[i] == lab[i + 1]:
ok &= sep[:, i] < MERGE_SEP
if 1 in present:
ok &= ~haploid
score[:, j] = np.where(ok, sc, -np.inf)
return np.asarray(labs[np.argmax(score, axis=1)], np.int64)
def fit_block(theta: F32, r: F32, use: npt.NDArray[np.bool_], haploid: npt.NDArray[np.bool_]) -> BlockFit:
"""Fit a block: ``theta``/``r`` (L, S) normalised intensities, ``use`` (L, S) samples that inform each locus
(sex-aware selection done by the caller), ``haploid`` (L,) loci without a heterozygous cluster."""
L, S = theta.shape
t = np.where(use & np.isfinite(theta) & np.isfinite(r) & (r > 0), theta.astype(np.float64), np.nan)
ok = np.isfinite(t)
nv = ok.sum(axis=1).astype(np.float64)
tz = np.nan_to_num(t, nan=0.5)
min_n = np.maximum(MIN_N, np.ceil(MIN_FRAC * nv))
# 1. mixtures with 1, 2 and 3 components, best start each; BIC among the admissible ones
best_bic = np.full(L, -np.inf)
k_best = np.ones(L, np.int64)
comp_of = np.full((L, S), -1) # hard component index (sorted by θ) of the chosen model
pbest = np.zeros((L, S)) # its max posterior per sample
means = np.full((L, 3), np.nan)
sds = np.full((L, 3), np.nan)
for k in (1, 2, 3):
ll_k = np.full(L, -np.inf)
m_k = np.zeros((L, k))
sd_k = np.zeros((L, k))
a_k = np.full((L, S), -1)
p_k = np.zeros((L, S))
for m0 in _starts(tz, ok, k):
m, sd, resp, ll = _em(tz, ok, m0, np.full((L, k), 0.04))
order = np.argsort(m, axis=1)
m = np.take_along_axis(m, order, axis=1)
sd = np.take_along_axis(sd, order, axis=1)
resp = np.take_along_axis(resp, order[:, None, :], axis=2)
pmax = resp.max(axis=2)
a = np.where(ok & (pmax > 0.5), resp.argmax(axis=2), -1)
better = ll > ll_k
ll_k = np.where(better, ll, ll_k)
m_k[better], sd_k[better], a_k[better], p_k[better] = m[better], sd[better], a[better], pmax[better]
cnt = np.stack([(a_k == j).sum(axis=1) for j in range(k)], axis=1)
valid = (cnt >= min_n[:, None]).all(axis=1)
if k > 1:
sep = np.diff(m_k, axis=1) / (sd_k[:, 1:] + sd_k[:, :-1])
valid &= (sep >= MIN_SEP).all(axis=1)
if k == 3:
valid &= ~haploid
bic = ll_k - 0.5 * (3 * k - 1) * np.log(np.maximum(nv, 2))
take = np.ones(L, bool) if k == 1 else valid & (bic > best_bic)
best_bic = np.where(take, bic, best_bic)
k_best[take] = k
comp_of[take], pbest[take] = a_k[take], p_k[take]
means[take] = np.nan
means[take, :k] = m_k[take]
sds[take] = np.nan
sds[take, :k] = sd_k[take]
# 2. component → genotype labels
lab = np.zeros((L, 3), np.int64) # lab[i, component] = genotype cluster (0 AA, 1 AB, 2 BB)
for k in (1, 2, 3):
sel = k_best == k
if sel.any():
cnt = np.stack([(comp_of[sel] == j).sum(axis=1) for j in range(k)], axis=1).astype(np.float64)
lab[sel, :k] = _label(cnt, means[sel, :k], sds[sel, :k], haploid[sel])
assign = np.where(comp_of >= 0, np.take_along_axis(lab, np.maximum(comp_of, 0), axis=1), -1)
counts = np.stack([(assign == j).sum(axis=1) for j in range(3)], axis=1)
keep = counts > 0
tm = np.full((L, 3), np.nan)
td = np.full((L, 3), np.nan)
rm = np.full((L, 3), np.nan)
rd = np.full((L, 3), np.nan)
rr = np.where(ok, r.astype(np.float64), np.nan)
for k in range(3):
sel = assign == k
with np.errstate(invalid="ignore", divide="ignore"):
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
tm[:, k] = np.nanmean(np.where(sel, t, np.nan), axis=1)
td[:, k] = np.nanstd(np.where(sel, t, np.nan), axis=1)
rm[:, k] = np.nanmean(np.where(sel, rr, np.nan), axis=1)
rd[:, k] = np.nanstd(np.where(sel, rr, np.nan), axis=1)
tm[~keep] = np.nan
n_out = np.where(keep, counts, 0).astype(np.int32)
# separation and training score
gaps = []
for a_, b_ in ((0, 1), (1, 2), (0, 2)):
both = keep[:, a_] & keep[:, b_]
if a_ == 0 and b_ == 2:
both &= ~keep[:, 1] # AA–BB gap only counts when AB is absent
g = np.abs(tm[:, b_] - tm[:, a_]) / (np.maximum(td[:, a_], SD_BOUNDS[0]) + np.maximum(td[:, b_], SD_BOUNDS[0]))
gaps.append(np.where(both, g, np.inf))
gap_std = np.min(np.stack(gaps, axis=1), axis=1)
sep = np.where(np.isfinite(gap_std), np.clip((gap_std - 1.0) / 3.0, 0.0, 1.0), 1.0)
with np.errstate(invalid="ignore", divide="ignore"):
conf = np.where(nv > 0, np.nansum(np.where(assign >= 0, pbest, 0.0), axis=1) / np.maximum(nv, 1), 0.0)
valid_frac = nv / np.maximum(use.sum(axis=1), 1)
score = np.clip(conf * (0.6 + 0.4 * sep) * valid_frac, 0.0, SCORE_CAP)
score = np.where(n_out.sum(axis=1) > 0, score, 0.0)
return BlockFit(n_out, tm.astype(np.float32), td.astype(np.float32), rm.astype(np.float32),
rd.astype(np.float32), sep.astype(np.float32), score.astype(np.float32),
conf.astype(np.float32), valid_frac.astype(np.float32)) # fmt: skip
@dataclass
class Relations:
"""Cross-locus relationships of well-clustered loci (step 3): medians, and least-squares lines that predict one
cluster's θ from a neighbour's (θ_AB = ab_from_aa[0] + ab_from_aa[1]·θ_AA, …)."""
d_aa: float # θ_AB − θ_AA
d_bb: float # θ_BB − θ_AB
r_aa: float # R_AA / R_AB
r_bb: float # R_BB / R_AB
td: tuple[float, float, float]
rcv: tuple[float, float, float] # R SD / R mean
ab_from_aa: tuple[float, float] = (0.4, 1.0)
ab_from_bb: tuple[float, float] = (-0.4, 1.0)
bb_from_ab: tuple[float, float] = (0.4, 1.0)
aa_from_ab: tuple[float, float] = (-0.4, 1.0)
bb_from_aa: tuple[float, float] = (0.8, 1.0)
aa_from_bb: tuple[float, float] = (-0.8, 1.0)
def learn_relations(fit: BlockFit, min_n: int = 10) -> Relations:
good = (fit.n >= min_n).all(axis=1)
if good.sum() < 20:
return Relations(0.4, 0.4, 1.0, 1.0, (0.03, 0.05, 0.03), (0.12, 0.12, 0.12))
tm, rm = fit.theta_mean[good].astype(np.float64), fit.r_mean[good].astype(np.float64)
td, rd = fit.theta_dev[good].astype(np.float64), fit.r_dev[good].astype(np.float64)
def line(x: F64, y: F64) -> tuple[float, float]:
b, a = np.polyfit(x, y, 1)
return float(a), float(b)
return Relations(float(np.median(tm[:, 1] - tm[:, 0])), float(np.median(tm[:, 2] - tm[:, 1])),
float(np.median(rm[:, 0] / rm[:, 1])), float(np.median(rm[:, 2] / rm[:, 1])),
tuple(float(np.median(td[:, k])) for k in range(3)), # type: ignore[arg-type]
tuple(float(np.median(rd[:, k] / rm[:, k])) for k in range(3)), # type: ignore[arg-type]
line(tm[:, 0], tm[:, 1]), line(tm[:, 2], tm[:, 1]), line(tm[:, 1], tm[:, 2]),
line(tm[:, 1], tm[:, 0]), line(tm[:, 0], tm[:, 2]), line(tm[:, 2], tm[:, 0])) # fmt: skip
def impute_missing(fit: BlockFit, rel: Relations) -> None:
"""Step 3, in place: fill θ/R means and SDs of clusters without samples (their n stays 0)."""
tm, rm = fit.theta_mean.astype(np.float64), fit.r_mean.astype(np.float64)
td, rd = fit.theta_dev.astype(np.float64), fit.r_dev.astype(np.float64)
have = fit.n > 0
for i in np.flatnonzero(~have.all(axis=1)).tolist():
h = have[i]
t, rr = tm[i].copy(), rm[i].copy()
if not h.any():
t[:] = (0.05, 0.5, 0.95)
rr[:] = 1.0
else:
def ln(c: tuple[float, float], x: float) -> float:
return c[0] + c[1] * x
if not h[1]:
if h[0] and h[2]:
t[1] = (ln(rel.ab_from_aa, t[0]) + ln(rel.ab_from_bb, t[2])) / 2
rr[1] = (rr[0] / rel.r_aa + rr[2] / rel.r_bb) / 2
elif h[0]:
t[1] = ln(rel.ab_from_aa, t[0])
rr[1] = rr[0] / rel.r_aa
else:
t[1] = ln(rel.ab_from_bb, t[2])
rr[1] = rr[2] / rel.r_bb
if not h[0]:
t[0] = ln(rel.aa_from_bb, t[2]) if (h[2] and not h[1]) else ln(rel.aa_from_ab, t[1])
rr[0] = rr[1] * rel.r_aa
if not h[2]:
t[2] = ln(rel.bb_from_aa, t[0]) if (h[0] and not h[1]) else ln(rel.bb_from_ab, t[1])
rr[2] = rr[1] * rel.r_bb
t = np.clip(t, 0.0, 1.0)
t[1] = min(max(t[1], t[0] + 0.02), t[2] - 0.02) if t[2] - t[0] > 0.04 else (t[0] + t[2]) / 2
for k in range(3):
if not h[k]:
tm[i, k], rm[i, k] = t[k], max(rr[k], 1e-3)
td[i, k] = rel.td[k]
rd[i, k] = rel.rcv[k] * rm[i, k]
td = np.where(np.isfinite(td) & (td > 0), td, np.array(rel.td)[None, :])
rd = np.where(np.isfinite(rd) & (rd > 0), rd, np.array(rel.rcv)[None, :] * rm)
fit.theta_mean, fit.r_mean = tm.astype(np.float32), rm.astype(np.float32)
fit.theta_dev = np.maximum(td, SD_BOUNDS[0]).astype(np.float32)
fit.r_dev = rd.astype(np.float32)
+17 -10
View File
@@ -178,18 +178,24 @@ def test_info_and_extra_format_match_gtc2vcf(tmp_path: Path) -> None: # OUT-012
) # fmt: skip ) # fmt: skip
assert res.exit_code == 0, res.output assert res.exit_code == 0, res.output
env = dict(os.environ, BCFTOOLS_PLUGINS=str(plugin.parent)) env = dict(os.environ, BCFTOOLS_PLUGINS=str(plugin.parent))
g2v = subprocess.run( g2v = tmp_path / "g2v.vcf"
with g2v.open("w") as fh: # streamed: the text of ~700 000 records would cost GBs held as dicts
subprocess.run(
[str(BCFTOOLS), "+gtc2vcf", "--no-version", "-t", "GT,X,Y,NORMX,NORMY,THETA,R", "-b", str(GSA_BPM), "-c", [str(BCFTOOLS), "+gtc2vcf", "--no-version", "-t", "GT,X,Y,NORMX,NORMY,THETA,R", "-b", str(GSA_BPM), "-c",
str(GSA_CSV), "-e", str(GSA_EGT), "-f", str(FASTA), "-g", str(gtcs), "-O", "v"], str(GSA_CSV), "-e", str(GSA_EGT), "-f", str(FASTA), "-g", str(gtcs), "-O", "v"],
capture_output=True, text=True, env=env, check=True, stdout=fh, env=env, check=True,
).stdout # fmt: skip ) # fmt: skip
ref = {} keys = ("X", "Y", "NORMX", "NORMY", "THETA", "R")
for line in g2v.splitlines(): ref: dict[str, tuple[str, ...]] = {} # id → (ASSAY_TYPE, GenTrain_Score, Cluster_Sep, *keys)
with g2v.open() as fh:
for line in fh:
if line.startswith("#"): if line.startswith("#"):
continue continue
c = line.split("\t") c = line.rstrip("\n").split("\t")
info = dict(kv.split("=", 1) for kv in c[7].split(";") if "=" in kv) info = dict(kv.split("=", 1) for kv in c[7].split(";") if "=" in kv)
ref[c[2]] = (info, dict(zip(c[8].split(":"), c[9].split(":"), strict=True))) fmt = dict(zip(c[8].split(":"), c[9].split(":"), strict=True))
ref[c[2]] = (info["ASSAY_TYPE"], info["GenTrain_Score"], info["Cluster_Sep"], *(fmt[k] for k in keys))
g2v.unlink()
def close(a: str, b: str) -> bool: def close(a: str, b: str) -> bool:
na, nb = a in (".", "") or "nan" in a.lower(), b in (".", "") or "nan" in b.lower() na, nb = a in (".", "") or "nan" in a.lower(), b in (".", "") or "nan" in b.lower()
@@ -207,9 +213,10 @@ def test_info_and_extra_format_match_gtc2vcf(tmp_path: Path) -> None: # OUT-012
continue continue
info = dict(kv.split("=", 1) for kv in c[7].split(";")) info = dict(kv.split("=", 1) for kv in c[7].split(";"))
fmt = dict(zip(c[8].split(":"), c[9].split(":"), strict=True)) fmt = dict(zip(c[8].split(":"), c[9].split(":"), strict=True))
ri, rf = ref[c[2]] at, gt_score, sep, *vals = ref[c[2]]
assert info["ASSAY_TYPE"] == ri["ASSAY_TYPE"] rf = dict(zip(keys, vals, strict=True))
assert close(info["GENTRAIN"], ri["GenTrain_Score"]) and close(info["CLUSTER_SEP"], ri["Cluster_Sep"]) assert info["ASSAY_TYPE"] == at
assert close(info["GENTRAIN"], gt_score) and close(info["CLUSTER_SEP"], sep)
for k in ("X", "Y", "NORMX", "NORMY"): for k in ("X", "Y", "NORMX", "NORMY"):
assert close(fmt[k], rf[k]), (c[2], k, fmt[k], rf[k]) assert close(fmt[k], rf[k]), (c[2], k, fmt[k], rf[k])
if fmt["NORMX"] == "0" and fmt["NORMY"] == "0": if fmt["NORMX"] == "0" and fmt["NORMY"] == "0":
+75
View File
@@ -0,0 +1,75 @@
"""PGx on the GSA v4 PGx demo (30 CEPH individuals) — definition sanity (PGX-023), array limitations (PGX-009),
determinism (PGX-029) and JSON ↔ TSV equivalence (PGX-028).
Needs the local PGx DB (``simurg pgx db build`` with the GSA v4 PGx manifest, see docs/validation/V2_pgx_star.md) and
the demo SNV + PGx CN VCFs under ``data/pgx/demo``; skipped when absent.
"""
from __future__ import annotations
import csv
import json
import sqlite3
from pathlib import Path
import pytest
from tests.conftest import ROOT
from typer.testing import CliRunner
from simurg.cli.main import app
pytestmark = pytest.mark.data
DEMO = ROOT / "data" / "pgx" / "demo"
DBDIR = ROOT / "data" / "pgx" / "dbm"
DBS = sorted(DBDIR.glob("pgx_db-*.sqlite")) if DBDIR.exists() else []
MANIFEST = "GSA-PGx-48v4-0_20079540_E2.bpm"
def _need() -> Path:
if not DBS or not (DEMO / "vcf").exists() or not (DEMO / "call").exists():
pytest.skip("PGx demo DB/VCFs not present")
return DBS[-1]
def test_definition_sanity() -> None: # PGX-023
db = _need()
con = sqlite3.connect(db)
assert con.execute("SELECT COUNT(*) FROM allele_variants WHERE gene = 'CYP2A6' AND allele = '*1'").fetchone() == (
0,
)
from simurg.pgx.phenotype import build_table, phenotype
rows = con.execute("SELECT lookup_key, activity_score, phenotype FROM phenotypes WHERE gene = 'ABCG2'").fetchall()
alle = con.execute("SELECT allele, function, activity FROM alleles WHERE gene = 'ABCG2'").fetchall()
ref = next(a for a, *_ in alle if "reference" in a)
assert phenotype(build_table("ABCG2", rows, alle), (ref, ref)).phenotype.startswith("Normal")
def test_known_limitations() -> None: # PGX-009 regression oracles that hold for our definitions
from simurg.pipeline.pgx_db import coverage_rows
st = {(r["gene"], r["allele"]): r["status"] for r in coverage_rows(_need(), MANIFEST)}
assert st[("CYP4F2", "*2")] == "not_covered" # reference: CYP4F2 *2 reported as *1
assert st[("CYP4F2", "*4")] == "equivalent_to:*3" # reference: *4 reported as *3
def test_determinism_and_json_tsv(tmp_path: Path) -> None: # PGX-029, PGX-028
db = _need()
run = CliRunner()
outs = []
for k in range(2):
out = tmp_path / f"s{k}"
r = run.invoke(app, ["pgx", "star-allele", "call", "--vcfs", str(DEMO / "vcf"), "--vcfs", str(DEMO / "call"),
"--db", str(db), "--out", str(out), "--gene", "CYP2D6", "--gene", "CYP2C19", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
r = run.invoke(app, ["pgx", "phenotype", "--calls", str(out), "--db", str(db), "--quiet"])
assert r.exit_code == 0, r.output
outs.append(out)
for name in ("diplotype_summary.tsv", "supporting_variants.tsv", "diplotype_annotations.tsv", "pgx_calls.json"):
assert (outs[0] / name).read_bytes() == (outs[1] / name).read_bytes(), name
with (outs[0] / "diplotype_annotations.tsv").open() as fh:
rows = list(csv.DictReader((ln for ln in fh if not ln.startswith("#")), delimiter="\t"))
for r in rows:
doc = json.loads((outs[0] / f"{r['sample_id']}.pgx.json").read_text())
g = next(x for x in doc["genes"] if x["gene"] == r["gene"])
assert g["genotype"] == r["genotype"] and (g["phenotype"] or "") == r["phenotype"]
+266
View File
@@ -0,0 +1,266 @@
"""Genome-wide CN/LOH/mosaic calling (spec CYTO-001 … CYTO-040)."""
from __future__ import annotations
import gzip
import json
from pathlib import Path
import numpy as np
import pytest
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.cn import hmm
from simurg.cn.events import Event, Thresholds, _mf_from_baf, apply_thresholds, complete_loss, effective_size, extract
from simurg.cn.gcwave import gc_correct
from simurg.cn.model import gc_fractions, population_bfreq
from simurg.formats.gtc import Gtc, write_gtc
from simurg.pipeline.cn import _mask_events, moving_median
from simurg.synth.files import SynthLocus, encode_bpm
# ------------------------------------------------------------------------------------------------ covariates
def test_gc_fractions() -> None: # CYTO-038
seq = np.frombuffer(b"GGGGAAAAAANNNNCCCC", dtype=np.uint8)
out = gc_fractions(seq, np.array([1, 13, 18]), (4, 36))
assert out[0, 0] == pytest.approx(1.0) # window [0, 2) clipped: "GG"
assert np.isnan(out[1, 0]) # window [10, 14) = "NNNN": no called base
assert out[2, 1] == pytest.approx(8 / 14) # whole sequence: 8 G/C over 14 called bases
def test_population_bfreq() -> None:
p = population_bfreq(np.array([[10, 0, 0], [0, 0, 10], [5, 10, 5], [0, 0, 0]], dtype=np.int32), 4)
assert p.tolist() == pytest.approx([0.0, 1.0, 0.5, 0.5])
def test_gc_correct_removes_wave() -> None: # CYTO-003
rng = np.random.default_rng(1)
gc = rng.uniform(0.3, 0.6, (5000, 3)).astype(np.float32)
noise = rng.normal(0, 0.05, 5000)
lrr = 0.8 * (gc[:, 2] - 0.45) + noise
out, fit = gc_correct(lrr, gc, np.ones(5000, dtype=np.bool_))
assert fit.applied and fit.gcwf > 0.02
assert abs(np.corrcoef(out, gc[:, 2])[0, 1]) < 0.05
same, fit2 = gc_correct(lrr[:10], gc[:10], np.ones(10, dtype=np.bool_))
assert not fit2.applied and np.array_equal(same, lrr[:10])
def test_moving_median_truncates_ends() -> None: # CYTO-004
x = np.array([1.0, 9.0, 2.0, 3.0, np.nan, 4.0])
m = moving_median(x, 1)
assert m[0] == pytest.approx(5.0) # window truncated at the start: median(1, 9)
assert m[2] == pytest.approx(3.0) and np.isnan(m[4])
assert np.array_equal(moving_median(x, 0), x, equal_nan=True)
# ------------------------------------------------------------------------------------------------ HMM + events
def _simulate(n: int = 4000, seed: int = 3) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
rng = np.random.default_rng(seed)
pos = np.arange(1, n + 1, dtype=np.int64) * 5000
pfb = np.full(n, 0.3)
geno = rng.choice([0, 1, 2], n, p=[0.49, 0.42, 0.09])
baf = np.choose(geno, [0.0, 0.5, 1.0]) + rng.normal(0, 0.03, n)
lrr = rng.normal(0, 0.15, n)
lrr[1000:1400] -= 0.45 # CN1 deletion: heterozygotes disappear
baf[1000:1400] = np.where(baf[1000:1400] > 0.25, 1.0, 0.0) + rng.normal(0, 0.02, 400)
baf[2500:3300] = np.where(baf[2500:3300] > 0.25, 1.0, 0.0) + rng.normal(0, 0.02, 800) # copy-neutral LOH
return pos, lrr, np.clip(baf, 0, 1), pfb
def test_hmm_finds_deletion_and_loh() -> None:
pos, lrr, baf, pfb = _simulate()
st = hmm.diploid_states()
prior = np.array([s.prior for s in st])
prior /= prior.sum()
e = hmm.emissions(st, lrr, baf, pfb, hmm.Params(0.15, 0.03))
sw = hmm.switch_probs(pos)
path = hmm.viterbi(e, sw, prior)
evs = extract("1", path, st, pos)
calls = {(ev.call, ev.i0 // 100, ev.i1 // 100) for ev in evs if ev.probes >= 100}
assert ("DEL", 10, 14) in calls
assert ("LOH", 25, 33) in calls
post = hmm.forward_backward(e, sw, prior)
assert np.allclose(post.sum(axis=1), 1.0)
assert post[1200, [0, 1]].sum() > 0.9
def test_effective_size_and_thresholds() -> None: # CYTO-007/008/010
pos = np.array([100, 200, 300, 400, 100_400, 100_500], dtype=np.int64)
assert effective_size(pos, 100.0) == (100_500 - 100 + 1) - (100_000 - 100)
th = Thresholds.defaults()
ev = Event("1", 1, 30_000, 0, 12, "DEL", 1, "CN1", effective_size=30_000)
small = Event("1", 1, 20_000, 0, 12, "DEL", 1, "CN1", effective_size=20_000)
counts: dict[str, int] = {}
assert apply_thresholds([ev, small], th, counts) == [ev] and counts["below_threshold"] == 1
def test_mask_trimming() -> None: # CYTO-017
mask = {"1": [(1_000, 1_999)]}
a = Event("1", 1_500, 10_000, 0, 20, "DEL", 1, "CN1")
b = Event("1", 1_100, 2_500, 0, 20, "DEL", 1, "CN1")
kept = _mask_events([a, b], mask, counts := {})
assert kept == [a] and a.start == 2_000 and "centromere_trimmed" in a.flags and counts["masked"] == 1
# ------------------------------------------------------------------------------------------------ CLI end to end
def _dataset(tmp: Path) -> tuple[Path, Path, Path]:
rng = np.random.default_rng(11)
n_per = 3000
loci = []
for c in ("1", "2"):
for k in range(n_per):
loci.append(SynthLocus(f"rs{c}_{k}", c, 5000 * (k + 1), "[A/G]", len(loci) + 1, 0, 0))
bpm = tmp / "m.bpm"
bpm.write_bytes(encode_bpm(loci))
fa = tmp / "g.fa"
body = {c: "".join(rng.choice(list("ACGT"), n_per * 5000 + 10_000)) for c in ("1", "2")}
fa.write_text("".join(f">{c}\n" + "\n".join(s[i : i + 60] for i in range(0, len(s), 60)) + "\n"
for c, s in body.items())) # fmt: skip
gtcs = tmp / "gtcs"
n = len(loci)
for sid, deletion in (("200000000001_R01C01", True), ("200000000001_R02C01", False)):
geno = rng.choice([1, 2, 3], n, p=[0.49, 0.42, 0.09]).astype(np.uint8)
baf = np.choose(geno - 1, [0.0, 0.5, 1.0]) + rng.normal(0, 0.03, n)
lrr = rng.normal(0, 0.12, n)
if deletion:
lrr[500:900] -= 0.45
baf[500:900] = np.where(baf[500:900] > 0.25, 1.0, 0.0)
geno[500:900] = np.where(geno[500:900] == 2, 3, geno[500:900])
g = Gtc(num_snps=n, genotypes=geno, genotype_scores=np.full(n, 0.9, np.float32),
b_allele_freqs=np.clip(baf, 0, 1).astype(np.float32), logr_ratios=lrr.astype(np.float32),
raw_x=np.full(n, 1000, np.uint16), raw_y=np.full(n, 1000, np.uint16),
base_calls=np.zeros((n, 2), np.uint8), gender=b"F") # fmt: skip
(gtcs / sid).mkdir(parents=True)
write_gtc(g, gtcs / sid / f"{sid}.gtc")
return bpm, fa, gtcs
def test_cn_build_and_call(tmp_path: Path) -> None: # CYTO-028 .. CYTO-032, CYTO-037
bpm, fa, gtcs = _dataset(tmp_path)
model = tmp_path / "m.simurg-cyto"
r = CliRunner().invoke(app, ["cn", "build-model", "--bpm", str(bpm), "--fasta", str(fa), "--out", str(model),
"--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
out = tmp_path / "cn"
r = CliRunner().invoke(app, ["cn", "call", "--bpm", str(bpm), "--model", str(model), "--gtcs", str(gtcs),
"--fasta", str(fa), "--out", str(out), "--threads", "1", "--min-del-probes", "5",
"--unknown-sex-policy", "female", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
side = json.loads((out / "cn_call_manifest.json").read_text())
assert side["thresholds"]["min_del_probes"] == 10 # CYTO-008 floor
with gzip.open(out / "200000000001_R01C01.cyto.cnv.vcf.gz", "rt") as fh:
text = fh.read()
assert (out / "200000000001_R01C01.cyto.cnv.vcf.gz.tbi").exists()
head = [ln for ln in text.splitlines() if ln.startswith("##")]
assert head[0] == "##fileformat=VCFv4.3" and any(h.startswith("##simurg_thresholds=") for h in head)
recs = [ln.split("\t") for ln in text.splitlines() if not ln.startswith("#")]
dels = [r_ for r_ in recs if r_[4] == "<DEL>"]
assert dels, text[-2000:]
for r_ in recs: # CYTO-029 / EDGE-025
pos, rid, info = int(r_[1]), r_[2], r_[7]
end = int(next(x for x in info.split(";") if x.startswith("END="))[4:])
s, e = rid.split(":")[2].split("-")
assert int(s) == pos + 1 and int(e) == end
assert r_[3] in "ACGT"
svlen = int(next(x for x in info.split(";") if x.startswith("SVLEN="))[6:])
assert (svlen < 0) == (r_[4] == "<DEL>")
assert r_[8] == "GT:CN:NR:LRD:MF"
d = dels[0]
assert d[0] == "1" and 2_300_000 < int(d[1]) < 2_700_000 and d[9].split(":")[:2] == ["0/1", "1"]
summ = (out / "cn_sample_summary.tsv").read_text().splitlines()
assert summ[0].startswith("sample_id\tsample_name\tstatus") and len(summ) == 3
assert (out / "200000000001_R01C01.cn_segments.parquet").exists()
# ------------------------------------------------------------------------------------------------ annotate
def test_iscn_strings_and_genes() -> None: # CYTO-034 / CYTO-035
from simurg.cn.annotate import Bands, Genes, iscn_sample
bands = Bands.from_ucsc("chr1\t0\t2300000\tp36.33\tgneg\nchr1\t2300000\t5300000\tp36.32\tgpos25\n"
"chr9\t0\t2200000\tp24.3\tgneg\n") # fmt: skip
dele = {"chrom": "1", "start": 1_567_924, "end": 1_632_825, "call": "DEL", "call_type": "DEL", "copy_number": 1,
"mosaic": False} # fmt: skip
dup = {"chrom": "1", "start": 2_000_000, "end": 3_000_000, "call": "DUP", "call_type": "DUP", "copy_number": 3,
"mosaic": False} # fmt: skip
loh = {"chrom": "9", "start": 100, "end": 2_000_000, "call": "LOH", "call_type": "LOH", "copy_number": 2,
"mosaic": False} # fmt: skip
mos = {"chrom": "9", "start": 100, "end": 1_000, "call": "MDEL", "call_type": "DEL", "copy_number": 1,
"mosaic": True, "base_cn": 2} # fmt: skip
trad, micro = iscn_sample([loh, dup, dele], bands, "GRCh38", "F")
assert micro == ("arr[GRCh38] 1p36.33(1567924_1632825)x1,1p36.33p36.32(2000000_3000000)x3,"
"9p24.3(100_2000000)x2 hmz") # fmt: skip
assert trad == "del(1)(p36.33p36.33),dup(1)(p36.33p36.32)"
assert iscn_sample([mos], bands, "GRCh38", "F")[1] == "arr[GRCh38] 9p24.3(100_1000)x2~1"
assert iscn_sample([], bands, "GRCh38", "M")[1] == "arr[GRCh38](1-22)x2,(XY)x1"
assert iscn_sample([], bands, "GRCh37", "F")[1] == "arr[GRCh37](1-22,X)x2"
assert iscn_sample([], bands, "GRCh38", "U")[1] == "arr[GRCh38](1-22)x2"
genes = Genes.from_rows([("1", 100, 5_000, "A"), ("1", 4_000, 4_500, "B"), ("1", 200, 300, "A"),
("1", 9_000, 9_100, "C")]) # fmt: skip
assert genes.overlapping("1", 4_400, 8_999) == ["A", "B"]
def test_cn_annotate_end_to_end(tmp_path: Path) -> None: # CYTO-032 / CYTO-033
bpm, fa, gtcs = _dataset(tmp_path)
model, out = tmp_path / "m.simurg-cyto", tmp_path / "cn"
run = CliRunner().invoke
assert run(app, ["cn", "annotate", "--vcfs", str(tmp_path)]).exit_code == 2 # E_CN_SIDECAR_MISSING
assert run(app, ["cn", "build-model", "--bpm", str(bpm), "--fasta", str(fa), "--out", str(model),
"--quiet"]).exit_code == 0 # fmt: skip
r = run(app, ["cn", "call", "--bpm", str(bpm), "--model", str(model), "--gtcs", str(gtcs), "--fasta", str(fa),
"--out", str(out), "--threads", "1", "--unknown-sex-policy", "female", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
r = run(app, ["cn", "annotate", "--vcfs", str(out), "--min-qual", "0", "--quiet"])
assert r.exit_code == 0, r.output
doc = json.loads((out / "200000000001_R01C01.cyto.json").read_text())
assert doc["schema"] == "simurg.cyto/1" and doc["annotation_type"] == "constitutional"
assert doc["iscn_microarray"].startswith("arr[")
assert any(e["call_type"] == "DEL" and e["chrom"] == "1" for e in doc["events"])
assert {c["id"] for c in doc["chromosomes"]} == {"1", "2"}
normal = json.loads((out / "200000000001_R02C01.cyto.json").read_text())
assert normal["iscn_microarray"] in ("arr[GRCh38](1-22,X)x2", "arr[GRCh37](1-22,X)x2") or normal["events"]
def test_packaged_cn_data() -> None: # the built-in mask and cytobands ship with the package (CYTO-017, CYTO-035)
from simurg.pipeline.cn import _builtin_file, read_mask
for build in ("GRCh37", "GRCh38"):
m = read_mask(None, build)
assert len(m) >= 24 and all(s <= e for iv in m.values() for s, e in iv)
assert gzip.decompress(_builtin_file(f"cytoBand_{build}.txt.gz")).startswith(b"chr1\t0\t")
def test_mf_refinements() -> None: # CYTO-018 / CYTO-041
rng = np.random.default_rng(3)
sd = 0.04
# unsplit heterozygotes: noise alone must not look like a mosaic split
b = 0.5 + rng.normal(0, sd, 400)
mf = _mf_from_baf("MDEL", b, sd)
assert mf is not None and mf < 0.08
assert (_mf_from_baf("MDEL", b) or 0) > 0.05 # uncorrected, the old estimate was biased upwards
# a true 40 % mosaic loss is still recovered
d = 0.4 / (2 * (2 - 0.4))
b = 0.5 + rng.choice([-d, d], 400) + rng.normal(0, sd, 400)
assert abs((_mf_from_baf("MDEL", b, sd) or 0) - 0.4) < 0.08
# no heterozygous calls where ~12 are expected: a complete (constitutional) loss
hom = np.full(30, 1, dtype=np.uint8)
assert complete_loss(hom, np.full(30, 0.3))
assert not complete_loss(hom, np.full(30, 0.02)) # too few expected
mixed = hom.copy()
mixed[:8] = 2
assert not complete_loss(mixed, np.full(30, 0.3))
def test_probe_lrr_noise() -> None: # CYTO-042
from simurg.cn.model import reference_lrr_spread
from simurg.pipeline.cn import _lrr_noise
rng = np.random.default_rng(5)
sd = np.array([0.1] * 50 + [0.3] * 50)
lrr = (rng.normal(0, 1, (40, 100)) * sd + rng.normal(0, 0.2, (40, 1))).astype(np.float32) # + sample offsets
spread = reference_lrr_spread(lrr, np.zeros(100, dtype=np.float32))
assert abs(float(np.median(spread[:50])) - 0.1) < 0.02 and abs(float(np.median(spread[50:])) - 0.3) < 0.05
auto = np.ones(100, dtype=np.bool_)
s = _lrr_noise(0.2, auto, spread)
assert np.median(s[50:]) / np.median(s[:50]) > 2
assert np.all(_lrr_noise(0.2, auto, None) == 0.2)
+234
View File
@@ -0,0 +1,234 @@
"""Methylation control QC (spec MET-001 … MET-018)."""
from __future__ import annotations
import gzip
import math
from pathlib import Path
import numpy as np
import pytest
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.core.errors import FormatError
from simurg.formats.methyl_manifest import read_methyl_manifest, write_probe_tables
from simurg.qc.controls import ControlBead, canonical_id, compute_control_metrics
from simurg.signal import methyl as sig
from simurg.synth.files import SynthIdat, encode_idat
HEAD = "Illumina, Inc.,,,\n[Heading],,,\nDescriptor File Name,SYNTH-METH_A1.csv,,\n[Assay],,,\n"
COLS = "IlmnID,Name,AddressA_ID,AlleleA_ProbeSeq,AddressB_ID,AlleleB_ProbeSeq,Infinium_Design_Type,Color_Channel\n"
CONTROLS = [ # (type, colour, name); one bead each
("STAINING", "Red", "DNP (High)"), ("STAINING", "Purple", "DNP (Bkg)"), ("STAINING", "Green", "Biotin (High)"),
("STAINING", "Blue", "Biotin (Bkg)"), ("EXTENSION", "Red", "Extension (A)"), ("EXTENSION", "Purple",
"Extension (T)"), ("EXTENSION", "Green", "Extension (C)"), ("EXTENSION", "Blue", "Extension (G)"),
("HYBRIDIZATION", "Green", "Hyb (High)"), ("HYBRIDIZATION", "Blue", "Hyb (Medium)"), ("HYBRIDIZATION", "Black",
"Hyb (Low)"), ("TARGET REMOVAL", "Green", "Target Removal 1"), ("TARGET REMOVAL", "Blue", "Target Removal 2"),
("RESTORATION", "Green", "Restore"), ("NON-POLYMORPHIC", "Red", "NP (A)"), ("NON-POLYMORPHIC", "Purple", "NP (T)"),
("NON-POLYMORPHIC", "Green", "NP (C)"), ("NON-POLYMORPHIC", "Blue", "NP (G)"),
*[("SPECIFICITY I", "Green", f"GT Mismatch {i} (PM)") for i in range(1, 7)],
*[("SPECIFICITY I", "Blue", f"GT Mismatch {i} (MM)") for i in range(1, 7)],
*[("SPECIFICITY II", "Red", f"Specificity {i}") for i in range(1, 4)],
*[("BISULFITE CONVERSION I", "Green", f"BS Conversion I-C{i}") for i in range(1, 6)],
*[("BISULFITE CONVERSION I", "Blue", f"BS Conversion I-U{i}") for i in range(1, 6)],
*[("BISULFITE CONVERSION II", "Red", f"BS Conversion II-{i}") for i in range(1, 5)],
*[("NEGATIVE", "Red", f"Negative {i}") for i in range(1, 41)],
*[(f"NORM_{b}", "Red", f"Norm_{b}{i}") for b in "ACGT" for i in range(1, 6)],
] # fmt: skip
N_T1R, N_T1G, N_T2 = 60, 60, 180
def _manifest(tmp: Path, *, controls: list[tuple[str, str, str]] | None = None, extra_field: bool = False) -> Path:
lines = [HEAD, COLS]
addr = 1000
for i in range(N_T1R + N_T1G + N_T2):
if i < N_T1R + N_T1G:
ch = "Red" if i < N_T1R else "Grn"
lines.append(f"cg{i:08d},cg{i:08d},{addr},ACGT,{addr + 1},ACGT,I,{ch}\n")
addr += 2
else:
lines.append(f"cg{i:08d},cg{i:08d},{addr},ACGT,,,II,\n")
addr += 1
lines.append("rs0000001,rs0000001,9001,ACGT,,,II,\n")
lines.append("ch.1.1,ch.1.1,9002,ACGT,,,II,\n")
lines.append("[Controls],,,\n")
for k, (t, c, n) in enumerate(controls if controls is not None else CONTROLS):
tail = ",AVG" if extra_field and t == "NEGATIVE" else ""
lines.append(f"{20000 + k},{t},{c},{n}{tail},,,,\n")
p = tmp / "meth.csv"
p.write_text("".join(lines))
return p
def _idats(folder: Path, man_path: Path, seed: int, barcode: str) -> None:
man = read_methyl_manifest(man_path)
rng = np.random.default_rng(seed)
addrs = np.unique(np.concatenate([man.u, man.m[man.m > 0], [c.address for c in man.controls]])).astype(np.int32)
n = len(addrs)
for ch in ("Grn", "Red"):
mean = rng.gamma(2.0, 2500.0, n).clip(50, 60000).astype(np.uint16)
folder.mkdir(parents=True, exist_ok=True)
(folder / f"{barcode}_R01C01_{ch}.idat").write_bytes(
encode_idat(SynthIdat(addrs, mean, n_beads=np.full(n, 8, np.uint8), barcode=barcode))
)
# ------------------------------------------------------------------------------------------------ manifest
def test_manifest_parse_and_tables(tmp_path: Path) -> None: # MET-002, MET-003
man = read_methyl_manifest(_manifest(tmp_path, extra_field=True))
assert man.num_probes == N_T1R + N_T1G + N_T2 + 2
assert int(man.type_i.sum()) == N_T1R + N_T1G
assert (man.col[:N_T1R] == "R").all() and (man.col[N_T1R : N_T1R + N_T1G] == "G").all()
assert (man.col[N_T1R + N_T1G :] == "").all()
assert int(man.analytical().sum()) == N_T1R + N_T1G + N_T2 + 1 # cg + rs, not ch
assert len(man.controls) == len(CONTROLS) and man.controls[0].probe_id == "ctl_20000"
probes, ctl = write_probe_tables(man, tmp_path / "t")
lines = probes.read_text().splitlines()
assert lines[0] == "probe_id\tu\tm\tcol"
assert lines[1] == "cg00000000\t1000\t1001\tR"
assert lines[N_T1R + N_T1G + 1].endswith("\tNA\tNA")
assert ctl.read_text().splitlines()[1] == "20000\tSTAINING\tRed\tDNP (High)\tctl_20000"
@pytest.mark.parametrize(
("text", "code"),
[
("[Heading],\nx,y\n[Controls]\n1,NEGATIVE,Red,N1\n", "E_METH_NO_ASSAY"),
("[Assay]\n" + COLS + "cg1,cg1,1,A,,,II,\n", "E_METH_NO_CONTROLS"),
("[Assay]\nIlmnID,Name,AddressA_ID\ncg1,cg1,1\n[Controls]\n1,NEGATIVE,Red,N1\n", "E_METH_MISSING_COLUMNS"),
("[Assay]\n" + COLS + "cg1,cg1,1,A,,,II,\n[Controls]\n1,NEGATIVE,Red\n", "E_METH_CONTROLS_FORMAT"),
],
)
def test_manifest_errors(tmp_path: Path, text: str, code: str) -> None:
p = tmp_path / "bad.csv"
p.write_text(text)
with pytest.raises(FormatError) as e:
read_methyl_manifest(p)
assert e.value.code == code
# ------------------------------------------------------------------------------------------------ numerics
def test_huber_matches_mass() -> None:
# MASS::huber(c(1, 2, 3, 4, 100)): mu is the fixed point of mu = (1 + 2 + 3 + 4 + (mu + 1.5 * s)) / 5
mu, s = sig.huber(np.array([1.0, 2.0, 3.0, 4.0, 100.0]))
assert s == pytest.approx(1.4826)
assert mu == pytest.approx((10 + 1.5 * 1.4826) / 4, abs=1e-5)
with pytest.raises(ValueError):
sig.huber(np.ones(5))
def test_mills_ratio() -> None:
assert sig._mills(np.array([0.0]))[0] == pytest.approx(math.sqrt(2 / math.pi))
lo, hi = sig._mills(np.array([-30.0001, -29.9999]))
assert lo == pytest.approx(hi, rel=1e-5)
z = np.array([-5.0, -1.0, 2.0])
phi = np.exp(-z * z / 2) / math.sqrt(2 * math.pi)
big = np.array([0.5 * math.erfc(-v / math.sqrt(2)) for v in z])
assert np.allclose(sig._mills(z), phi / big, rtol=1e-12)
def test_normexp_signal() -> None:
x = np.array([1.0, 500.0, 5000.0, 60000.0])
out = sig.normexp_signal(400.0, 150.0, 3000.0, x)
assert (out > 0).all() and (np.diff(out) > 0).all()
assert out[-1] == pytest.approx(60000.0 - 400.0 - 150.0**2 / 3000.0, rel=1e-9)
def test_detection_and_beta() -> None: # MET-010, MET-011
s = sig.MethylSignals(
m=np.array([5000.0, 100.0, 3000.0, np.nan]),
u=np.array([100.0, 90.0, 4000.0, np.nan]),
oob_m=np.array([200.0, 150.0, np.nan, np.nan]),
oob_u=np.array([250.0, 300.0, np.nan, np.nan]),
masked=np.array([False, False, False, True]),
col=np.array(["R", "G", "", ""]),
ctrl_green=np.array([120.0, 130.0]),
ctrl_red=np.array([110.0, 140.0]),
ctrl_type=["NEGATIVE", "NEGATIVE"],
)
p = sig.detection_p(s)
assert p[0] == 0.0 # far above the red background
assert p[1] > 0.5 # at the green background
assert p[3] == 1.0 # masked
b = sig.beta_values(s)
assert b[0] == pytest.approx(5000 / 5100)
assert sig.beta_values(s, 100.0)[0] == pytest.approx(5000 / 5200)
assert np.isnan(b[3])
mv = sig.m_values(np.array([0.0, 0.5, 1.0]))
assert mv[1] == 0.0 and mv[0] == pytest.approx(math.log2(1e-6 / (1 - 1e-6)))
def test_methylation_metrics(tmp_path: Path) -> None: # MET-006
man = read_methyl_manifest(_manifest(tmp_path))
beads = [ControlBead(c.type, c.color_channel, c.name, c.address, canonical_id(c.name)) for c in man.controls]
assert all(b.canonical for b in beads if not b.category.startswith(("NEGATIVE", "NORM")))
g = np.full(len(beads), 1000.0)
r = np.full(len(beads), 1000.0)
idx = {b.name: i for i, b in enumerate(beads)}
for nm, v in (("BS Conversion I-C3", 8000.0), ("BS Conversion I-U3", 400.0), ("BS Conversion I-U5", 500.0)):
r[idx[nm]] = v
r[idx["BS Conversion I-C4"]] = r[idx["BS Conversion I-C5"]] = 9000.0
q = compute_control_metrics(beads, g, r, "methylation", 3000.0)
assert set(q.values) == {m for m in q.values} and len(q.values) == 21
assert q.values["bs_conv_1_red"] == pytest.approx(8000 / 1000) # max(U3..U5) = 1000 (U4)
assert q.values["target_removal_2"] == pytest.approx((1000 + 3000) / 1000)
q2 = compute_control_metrics(beads, g, r, "methylation", 3000.0, "U4-U6")
assert q2.missing.get("bs_conv_1_red") == ["bs1_U6"]
# ------------------------------------------------------------------------------------------------ CLI
def test_methyl_qc_end_to_end(tmp_path: Path) -> None: # MET-013
man = _manifest(tmp_path)
for k in range(3):
_idats(tmp_path / "idats" / f"20400000000{k}", man, k, f"20400000000{k}")
out = tmp_path / "out"
r = CliRunner().invoke(app, ["methyl", "qc", "--manifest", str(man), "--idats", str(tmp_path / "idats"),
"--out", str(out), "--threads", "1", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
for f in ("methyl_qc_summary.tsv", "methyl_qc_summary.xlsx", "methyl_qc_summary.html", "pcs.tsv.gz",
"probes.tsv", "controls.tsv", "controls_raw.tsv", "controls_qc.tsv", "run_manifest.json"): # fmt: skip
assert (out / f).exists(), f
rows = [ln.split("\t") for ln in (out / "methyl_qc_summary.tsv").read_text().splitlines()]
head = rows[0]
assert head[:3] == ["sentrix_id", "sentrix_position", "sample_id"] and len(rows) == 4
for c in ("bs_conv_2_bg", "avg_green_norm", "n_probes", "prop_probes_passing", "pass_qc", "fail_codes"):
assert c in head
assert {r_[head.index("n_probes")] for r_ in rows[1:]} == {str(N_T1R + N_T1G + N_T2 + 1)}
sid = rows[1][2]
with gzip.open(out / "samples" / sid / f"{sid}_cgs.tsv.gz", "rt") as fh:
cgs = fh.read().splitlines()
assert cgs[0] == "probe_id\tbeta\tm_value\tdetection_p" and len(cgs) == N_T1R + N_T1G + N_T2 + 3
with gzip.open(out / "pcs.tsv.gz", "rt") as fh:
assert len(fh.read().splitlines()) == 4
# the folder is a valid qc report dataset (MET-014)
r = CliRunner().invoke(app, ["qc", "report", "--data", str(out), "--out", str(tmp_path / "rep"), "--quiet"])
assert r.exit_code == 0, r.output
def test_methyl_qc_missing_types_and_no_idats(tmp_path: Path) -> None: # MET-009, MET-004
man = _manifest(tmp_path, controls=[c for c in CONTROLS if c[0] != "RESTORATION"])
_idats(tmp_path / "idats" / "204000000009", man, 9, "204000000009")
out = tmp_path / "out"
r = CliRunner().invoke(app, ["methyl", "qc", "--manifest", str(man), "--idats", str(tmp_path / "idats"),
"--out", str(out), "--threads", "1"]) # fmt: skip
assert r.exit_code == 0, r.output
assert "W_METH_MISSING_CONTROL_NAMES" in r.output
assert "restoration" in (out / "methyl_qc_summary.tsv").read_text().splitlines()[1]
(tmp_path / "empty").mkdir()
r = CliRunner().invoke(app, ["methyl", "qc", "--manifest", str(man), "--idats", str(tmp_path / "empty"),
"--out", str(tmp_path / "o2")]) # fmt: skip
assert r.exit_code == 2 and "E_METH_NO_IDATS" in r.output
def test_convert_manifest_and_qc_controls(tmp_path: Path) -> None: # MET-003, QC-012
man = _manifest(tmp_path)
r = CliRunner().invoke(app, ["methyl", "convert-manifest", "--manifest", str(man), "--out", str(tmp_path / "c")])
assert r.exit_code == 0, r.output
assert (tmp_path / "c" / "probes.tsv").exists()
_idats(tmp_path / "idats" / "204000000001", man, 1, "204000000001")
r = CliRunner().invoke(app, ["qc", "controls", "--array-type", "methylation", "--csv", str(man), "--idats",
str(tmp_path / "idats"), "--out", str(tmp_path / "q"), "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
qc = (tmp_path / "q" / "controls_qc.tsv").read_text().splitlines()
assert "bs_conv_2_bg" in qc[0] and "stringency" not in qc[0] and qc[1].split("\t")[1] == "methylation"
+189
View File
@@ -0,0 +1,189 @@
"""Targeted PGx copy number (spec PCN-001 … PCN-017, CLI-031/032, IN-050)."""
from __future__ import annotations
import gzip
from pathlib import Path
import numpy as np
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.formats.gtc import Gtc, write_gtc
from simurg.pgx.cn import TEMPLATE, call_cn, fit_region, hwe_weights, parse_regions
from simurg.pipeline.pgx_cn import default_regions
from simurg.synth.files import SynthLocus, encode_bpm
def _cluster_values(rng: np.random.Generator, cns: np.ndarray, delta: float, gamma: float, sd: float,
cn0_drop: float = 0.0) -> np.ndarray: # fmt: skip
x = delta + gamma * TEMPLATE[cns] + rng.normal(0, sd, len(cns))
return np.where(cns == 0, x + cn0_drop, x)
def _hwe_cns(rng: np.random.Generator, n: int, f: list[float]) -> np.ndarray:
return rng.choice(len(f), n, p=f) + rng.choice(len(f), n, p=f)
def test_hwe_weights() -> None: # PCN-009 [OURS]
w = hwe_weights(np.array([0.3, 0.6, 0.1, 0.0]))
assert abs(w.sum() - 1) < 1e-12
assert np.allclose(w[:4], [0.09, 0.36, 0.06 + 0.36, 0.12])
def test_labels_identified() -> None: # PCN-009: GSTM1-like, GSTT1-like and one-cluster regions
rng = np.random.default_rng(1)
cns = _hwe_cns(rng, 60, [0.7, 0.3]) # common deletion: mostly CN 0 and 1
x = _cluster_values(rng, cns, 0.1, 0.22, 0.02)
f = fit_region(x)
assert np.mean([call_cn(v, f)[0] for v in x] == cns) > 0.95
cns = _hwe_cns(rng, 60, [0.45, 0.55])
x = _cluster_values(rng, cns, 0.3, 0.55, 0.02, cn0_drop=-1.2) # CN 0 far below the template
f = fit_region(x)
assert f.cn0_shift < -0.5
assert np.mean([call_cn(v, f)[0] for v in x] == cns) > 0.95
cns = np.full(40, 2)
cns[:2] = 1
x = _cluster_values(rng, cns, -0.05, 0.25, 0.015)
f = fit_region(x)
assert [call_cn(v, f)[0] for v in x] == cns.tolist()
def test_packaged_regions() -> None: # PCN-001
name, text = default_regions()
regs = parse_regions(text)
assert name.endswith(".v1.bed") and len(regs) == 10
assert {r.gene for r in regs} == {"CYP2D6", "CYP2A6", "GSTM1", "GSTT1", "UGT2B17", "SULT1A1", "CYP2E1"}
assert next(r for r in regs if r.gene == "GSTT1").chrom == "chr22_KI270879v1_alt"
def _dataset(tmp: Path, n_samples: int = 40, n_outliers: int = 0) -> tuple[Path, Path, Path, Path]:
rng = np.random.default_rng(5)
loci = []
for c in ("1", "2"):
for k in range(1500):
loci.append(SynthLocus(f"cnv{c}_{k}", c, 2000 * (k + 1), "[A/G]", len(loci) + 1, 0, 0))
bpm = tmp / "m.bpm"
bpm.write_bytes(encode_bpm(loci))
fa = tmp / "g.fa"
body = {c: "".join(rng.choice(list("ACGT"), 1500 * 2000 + 10_000)) for c in ("1", "2")}
fa.write_text("".join(f">{c}\n" + "\n".join(s[i : i + 60] for i in range(0, len(s), 60)) + "\n"
for c, s in body.items())) # fmt: skip
regions = tmp / "regions.bed"
regions.write_text("# test regions\n1\t100000\t160000\tGENEA\tGENEA\n2\t300000\t340000\tGENEB\tGENEB\n"
"3\t1000\t5000\tGENEC\tGENEC\n") # fmt: skip
n = len(loci)
pos = np.array([lc.pos for lc in loci])
chrom = np.array([lc.chrom for lc in loci])
in_a = (chrom == "1") & (pos > 100_000) & (pos <= 160_000)
in_b = (chrom == "2") & (pos > 300_000) & (pos <= 340_000)
cn_a = _hwe_cns(rng, n_samples, [0.5, 0.5])
gtcs = tmp / "gtcs"
truth = {}
for i in range(n_samples):
sid = f"2000000000{i:02d}_R01C01"
lrr = rng.normal(0, 0.08, n)
lrr[in_a] += 0.24 * TEMPLATE[cn_a[i]] + (-0.2 if cn_a[i] == 0 else 0.0)
cn_b = 3 if i == 0 else 2
lrr[in_b] += 0.25 * TEMPLATE[cn_b]
truth[sid] = (int(cn_a[i]), cn_b)
g = Gtc(num_snps=n, genotypes=np.ones(n, np.uint8), genotype_scores=np.full(n, 0.9, np.float32),
b_allele_freqs=np.zeros(n, np.float32), logr_ratios=lrr.astype(np.float32),
raw_x=np.full(n, 1000, np.uint16), raw_y=np.full(n, 1000, np.uint16),
base_calls=np.zeros((n, 2), np.uint8), gender=b"F", call_rate=0.995, logr_dev=0.12,
cluster_file="c.egt") # fmt: skip
(gtcs / sid).mkdir(parents=True)
write_gtc(g, gtcs / sid / f"{sid}.gtc")
for j in range(n_outliers): # EDGE-026: noisy, shifted arrays that still pass the LRR SD gate
sid = f"2000000099{j:02d}_R01C01"
lrr = rng.normal(0.3, 0.19, n)
g = Gtc(num_snps=n, genotypes=np.ones(n, np.uint8), genotype_scores=np.full(n, 0.9, np.float32),
b_allele_freqs=np.zeros(n, np.float32), logr_ratios=lrr.astype(np.float32),
raw_x=np.full(n, 1000, np.uint16), raw_y=np.full(n, 1000, np.uint16),
base_calls=np.zeros((n, 2), np.uint8), gender=b"F", call_rate=0.995, logr_dev=0.19,
cluster_file="c.egt") # fmt: skip
(gtcs / sid).mkdir(parents=True)
write_gtc(g, gtcs / sid / f"{sid}.gtc")
(tmp / "truth.txt").write_text("\n".join(f"{k}\t{a}\t{b}" for k, (a, b) in truth.items()))
return bpm, fa, gtcs, regions
def test_pgx_cn_train_and_call(tmp_path: Path) -> None: # CLI-031/032, PCN-002/004/011/012/013, IN-050
bpm, fa, gtcs, regions = _dataset(tmp_path)
run = CliRunner()
r = run.invoke(app, ["pgx", "copy-number", "train", "--gtcs", str(gtcs), "--bpm", str(bpm), "--fasta", str(fa),
"--out", str(tmp_path / "model"), "--regions", str(regions), "--platform", "EX",
"--quiet"]) # fmt: skip
assert r.exit_code == 2 and "E_PGX_TRAIN_TOO_SMALL" in r.output # 40 < 96 (PCN-005)
r = run.invoke(app, ["pgx", "copy-number", "train", "--gtcs", str(gtcs), "--bpm", str(bpm), "--fasta", str(fa),
"--out", str(tmp_path / "model"), "--regions", str(regions), "--min-samples", "30", "--platform", "EX",
"--min-passing", "30", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
model = tmp_path / "model" / "m.simurg-pgxcn"
assert model.exists() and (tmp_path / "model" / "train_exclusions.tsv").exists()
out = tmp_path / "call"
r = run.invoke(app, ["pgx", "copy-number", "call", "--gtcs", str(gtcs), "--model", str(model), "--fasta", str(fa),
"--out", str(out), "--min-batch", "50", "--quiet"]) # fmt: skip
assert r.exit_code == 2 and "E_PGX_BATCH_TOO_SMALL" in r.output
r = run.invoke(app, ["pgx", "copy-number", "call", "--gtcs", str(gtcs), "--model", str(model), "--fasta", str(fa),
"--out", str(out), "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
truth = {ln.split("\t")[0]: (int(ln.split("\t")[1]), int(ln.split("\t")[2]))
for ln in (tmp_path / "truth.txt").read_text().splitlines()} # fmt: skip
ok_a = ok_b = 0
for sid, (a, b) in truth.items():
with gzip.open(out / f"{sid}.pgx_cn.vcf.gz", "rt") as fh:
text = fh.read()
assert (out / f"{sid}.pgx_cn.vcf.gz.tbi").exists()
recs = [ln.split("\t") for ln in text.splitlines() if not ln.startswith("#")]
assert [x[7].split("REGION=")[1].split(";")[0] for x in recs] == ["GENEA", "GENEB", "GENEC"] # PCN-002
for x in recs: # PCN-011: POS = S − 1, ID coordinates = POS + 1 … END
end = int(x[7].split("END=")[1].split(";")[0])
s_, e_ = x[2].split(":")[-1].split("-")
assert int(s_) == int(x[1]) + 1 and int(e_) == end and x[8] == "CN:NR"
genec = recs[2]
assert genec[6] == "NoCall" and genec[9].startswith(".:")
ok_a += int(recs[0][9].split(":")[0]) == a
ok_b += int(recs[1][9].split(":")[0]) == b
assert ok_a >= 0.95 * len(truth) and ok_b >= 0.9 * len(truth), (ok_a, ok_b)
rows = [ln.split("\t") for ln in (out / "pgx_cn_batch_summary.tsv").read_text().splitlines()]
assert rows[0] == ["region", "gene", "n_gain", "n_loss", "n_neutral", "n_nocall", "n_filtered"]
assert [r_[0] for r_ in rows[1:]] == ["GENEA", "GENEB", "GENEC", "ALL"]
for r_ in rows[1:-1]: # PCN-012: the four counts add up to N
assert sum(int(v) for v in r_[2:6]) == len(truth)
head = (out / "pgx_cn_sample_summary.tsv").read_text().splitlines()[0].split("\t")
assert head[:6] == ["sample_id", "sample_name", "sample_folder", "logr_dev", "passed_batch_qc",
"used_in_reference"] and head[-1] == "flags" # fmt: skip
def _calls(out: Path) -> dict[str, str]:
rows = [ln.split("\t") for ln in (out / "pgx_cn_sample_summary.tsv").read_text().splitlines()]
head = rows[0]
return {r[0]: ":".join(r[head.index("cn_GENEA")] + r[head.index("cn_GENEB")]) for r in rows[1:]}
def test_outlier_robustness(tmp_path: Path) -> None: # PCN-017 / EDGE-026: 10 % outliers leave other calls alone
run = CliRunner()
res = {}
for tag, n_out in (("clean", 0), ("outliers", 4)):
d = tmp_path / tag
d.mkdir()
bpm, fa, gtcs, regions = _dataset(d, 40, n_out)
r = run.invoke(app, ["pgx", "copy-number", "train", "--gtcs", str(gtcs), "--bpm", str(bpm), "--fasta", str(fa),
"--out", str(d / "model"), "--regions", str(regions), "--min-samples", "30",
"--min-passing", "30", "--platform", "EX", "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
r = run.invoke(app, ["pgx", "copy-number", "call", "--gtcs", str(gtcs), "--model", str(d / "model" / "m.simurg-pgxcn"),
"--fasta", str(fa), "--out", str(d / "call"), "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
res[tag] = _calls(d / "call")
assert (d / "call" / "pgx_cn_baf_evidence.tsv").exists() # PCN-010 output
same = sum(res["clean"][k] == res["outliers"][k] for k in res["clean"])
assert same >= 0.99 * len(res["clean"])
def test_platform_required(tmp_path: Path) -> None: # CLI-032: --platform auto needs a CSV manifest
bpm, fa, gtcs, regions = _dataset(tmp_path, 30)
r = CliRunner().invoke(app, ["pgx", "copy-number", "train", "--gtcs", str(gtcs), "--bpm", str(bpm), "--fasta",
str(fa), "--out", str(tmp_path / "m"), "--regions", str(regions), "--quiet"]) # fmt: skip
assert r.exit_code == 2 and "E_PGX_PLATFORM_UNKNOWN" in r.output
+201
View File
@@ -0,0 +1,201 @@
"""PGx DB build (spec PGX-001 … PGX-005, IN-053, CLI-035)."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typer.testing import CliRunner
from simurg.cli.main import app
from simurg.pgx.db import Window, cpic_definitions, normalize, parse_location, value_sequence
SEQ = "ACGTACGTTTTTAGAGAGAGCCCGGGTATATATAGGCCAATTGGCCAA" * 3
def fetch(contig: str, s0: int, e0: int) -> str:
return SEQ[s0:e0]
def test_parse_location() -> None:
assert parse_location("g.5C>T") == Window(5, 5, "snv")
assert parse_location("g.9_12del") == Window(9, 12, "del")
assert parse_location("g.13_14insGA") == Window(13, 13, "ins")
assert parse_location("g.20dup") == Window(20, 20, "dup")
assert parse_location("g.13AG[4]") == Window(13, 13, "repeat", "AG")
assert parse_location("g.21_22delinsTT") == Window(21, 22, "delins")
assert parse_location("m.961delT; m.961delTinsC(n)") == Window(961, 961, "del")
def test_normalize_left_aligns() -> None: # the SNV VCF representation (IN-046)
# deleting one T of TTTTT (positions 8-12) → anchored at the base before the run
assert normalize(fetch, "1", 11, 11, "") == (7, "GT", "G")
# inserting AG into AGAGAGAG (13-20) → left-most position
assert normalize(fetch, "1", 21, 20, "AG") == (12, "T", "TAG")
assert normalize(fetch, "1", 5, 5, "G") == (5, "A", "G")
w = parse_location("g.13AG[4]")
assert w is not None
s, e, alt = value_sequence("AG(5)", w, fetch, "1") or (0, 0, "")
assert (s, e) == (13, 20) and normalize(fetch, "1", s, e, alt) == (12, "T", "TAG")
assert value_sequence("AG(3) or AG(4)", w, fetch, "1") is None # ambiguous CPIC value
def _tables() -> dict[str, list[dict]]:
loc = [
{"id": 1, "name": "5A>G", "chromosomelocation": "g.5A>G", "genesymbol": "GENE1", "dbsnpid": "rs1"},
{"id": 2, "name": "11delT", "chromosomelocation": "g.11del", "genesymbol": "GENE1", "dbsnpid": "rs2"},
{"id": 3, "name": "30C>T", "chromosomelocation": "g.30G>T", "genesymbol": "GENE1", "dbsnpid": "rs3"},
]
defs = [
{"id": 10, "genesymbol": "GENE1", "name": "*1", "matchesreferencesequence": True, "structuralvariation": False},
{
"id": 11,
"genesymbol": "GENE1",
"name": "*2",
"matchesreferencesequence": False,
"structuralvariation": False,
},
{
"id": 12,
"genesymbol": "GENE1",
"name": "*3",
"matchesreferencesequence": False,
"structuralvariation": False,
},
{"id": 13, "genesymbol": "GENE1", "name": "*5", "matchesreferencesequence": False, "structuralvariation": True},
]
alv = [
{"alleledefinitionid": 10, "locationid": 1, "variantallele": "A"},
{"alleledefinitionid": 10, "locationid": 2, "variantallele": "T"},
{"alleledefinitionid": 10, "locationid": 3, "variantallele": "G"},
{"alleledefinitionid": 11, "locationid": 1, "variantallele": "G"},
{"alleledefinitionid": 11, "locationid": 3, "variantallele": "K"}, # IUPAC: not constraining
{"alleledefinitionid": 12, "locationid": 2, "variantallele": "delT"},
]
allele = [
{"genesymbol": "GENE1", "name": "*1", "clinicalfunctionalstatus": "Normal function", "activityvalue": "1.0",
"frequency": {"European": 0.7}},
{"genesymbol": "GENE1", "name": "*2", "clinicalfunctionalstatus": "No function", "activityvalue": "0.0",
"frequency": {"European": 0.2}},
{"genesymbol": "GENE1", "name": "*3", "clinicalfunctionalstatus": "Decreased function", "activityvalue": "0.5",
"frequency": None},
] # fmt: skip
return {"gene": [{"symbol": "GENE1", "chr": "chr1"}], "sequence_location": loc, "allele_definition": defs,
"allele_location_value": alv, "allele": allele,
"gene_result": [{"id": 50, "genesymbol": "GENE1", "result": "Poor Metabolizer"}],
"gene_result_lookup": [{"phenotypeid": 50, "lookupkey": {"No function": 2}, "totalactivityscore": "0.0"}]} # fmt: skip
def test_cpic_definitions() -> None: # PGX-002: variants, wildcards, undefined structural alleles
defs, sites, problems = cpic_definitions(_tables(), fetch, lambda c: "1")
assert not problems
by = {d.name: d for d in defs["GENE1"]}
assert by["*1"].reference and not by["*1"].variants
assert [sites[lid].alleles[v] for lid, v in by["*2"].variants] == [(5, "A", "G")]
assert by["*2"].wildcards == [3]
assert [sites[lid].alleles[v] for lid, v in by["*3"].variants] == [(7, "GT", "G")]
assert by["*5"].undefined and by["*5"].structural
def test_db_build_deterministic(tmp_path: Path) -> None: # IN-053, CLI-035
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")
outs = []
for k in range(2):
out = tmp_path / f"db{k}"
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
outs.append(next(out.glob("pgx_db-1-*.sqlite")))
assert outs[0].name == outs[1].name and outs[0].read_bytes() == outs[1].read_bytes()
con = sqlite3.connect(outs[0])
assert con.execute("select result_basis from genes").fetchall() == [("star_allele",)]
assert con.execute("select allele, pos, ref, alt from allele_variants order by allele").fetchall() == [
("*2", 5, "A", "G"), ("*3", 7, "GT", "G")] # fmt: skip
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 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"
def test_pharmvar_core_alleles() -> None: # PGX-001 PharmVar source
from simurg.pgx.db import pharmvar_core_alleles
vcf = "##fileformat=VCFv4.1\n#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\nchr19\t100\trs1\tA\tC\t.\t.\t.\n"
core = pharmvar_core_alleles("CYP2A6", {"CYP2A6-6.2/GRCh38/CYP2A6_9.vcf": vcf,
"CYP2A6-6.2/GRCh38/CYP2A6_9.001.vcf": vcf}) # fmt: skip
assert core == {"*9": [("chr19", 100, "A", "C", "rs1")]} # sub-alleles (*9.001) are not core definitions
def test_clinpgx_variant_genes() -> None: # PGX-008 variant-based genes from ClinPGx
from simurg.pgx.db import clinpgx_variant_genes
tsv = ("variant\tgene\ttype\tlevel of evidence\tchemicals\tphenotypes\n"
"rs1\tOPRM1\tEfficacy\t3\tx\t\nrs2\tFOO\tEfficacy\t3\tx\t\nrs3\tFOO\tEfficacy\t2A\tx\t\n"
"CYP2C9*3\tCYP2C9\tPK\t1A\tx\t\nrs4\tCYP2C9\tPK\t1A\tx\t\nrs5\tA,B\tPK\t1A\tx\t\n") # fmt: skip
assert clinpgx_variant_genes(tsv, {"CYP2C9"}) == {"OPRM1": {"rs1"}, "FOO": {"rs3"}}
def test_dpwg_mode(tmp_path: Path) -> None: # PGX-025
from simurg.core.log import Reporter
from simurg.pipeline.pgx_pheno import PhenoOptions, run_phenotype
src = tmp_path / "sources" / "cpic-20260101"
src.mkdir(parents=True)
t = _tables()
t["gene"][0]["symbol"] = "CYP2D6"
for tbl in ("sequence_location", "allele_definition", "allele", "gene_result"):
for r in t[tbl]:
r["genesymbol"] = "CYP2D6"
for k, v in t.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")
r = CliRunner().invoke(app, ["pgx", "db", "build", "--sources", str(tmp_path / "sources"), "--fasta", str(fa),
"--out", str(tmp_path / "db"), "--quiet"]) # fmt: skip
assert r.exit_code == 0, r.output
db = next((tmp_path / "db").glob("pgx_db-1-*.sqlite"))
calls = tmp_path / "calls"
calls.mkdir()
(calls / "pgx_calls.json").write_text(json.dumps({"samples": [{"sample_id": "s1", "genes": [
{"gene": "CYP2D6", "genotype": "*2/*2", "feasible_solutions": ["*2/*2"], "candidates": [
{"genotype": "*2/*2", "score": 1.0, "raw_score": 1.0}]},
{"gene": "CYP3A4", "genotype": "*1/*22", "feasible_solutions": ["*1/*22"], "candidates": []}]}]})) # fmt: skip
rep = Reporter(command="t", quiet=True)
assert run_phenotype(PhenoOptions(calls, db, guideline="dpwg"), rep) == 0
doc = json.loads((calls / "s1.pgx.json").read_text())
by = {g["gene"]: g for g in doc["genes"]}
assert by["CYP2D6"]["phenotype"] == "Poor Metabolizer" and "dpwg_via_cpic_mapping" in by["CYP2D6"]["flags"]
assert by["CYP3A4"]["phenotype"] is None and "dpwg_mapping_not_available" in by["CYP3A4"]["flags"]
+36
View File
@@ -0,0 +1,36 @@
"""Diplotype → phenotype (spec PGX-024, PGX-025)."""
from __future__ import annotations
import json
from simurg.pgx.phenotype import build_table, gene_phenotype, phenotype
ACT_ROWS = [(json.dumps({"1.0": 2}), "2.0", "Normal Metabolizer"), (json.dumps({"0.0": 1, "1.0": 1}), "1.0",
"Intermediate Metabolizer"), (json.dumps({"0.0": 2}), "0.0", "Poor Metabolizer"),
(json.dumps({"1.0": 1, "2.0": 1}), "3.0", "Ultrarapid Metabolizer")] # fmt: skip
ACT_ALLELES = [("*1", "Normal function", "1.0"), ("*4", "No function", "0.0"), ("*10", "Decreased function", "0.25")]
FUNC_ROWS = [(json.dumps({"Normal function": 2}), "n/a", "Normal Metabolizer"),
(json.dumps({"Normal function": 1, "No function": 1}), "n/a", "Intermediate Metabolizer")] # fmt: skip
FUNC_ALLELES = [("*1", "Normal function", None), ("*2", "No function", None)]
def test_activity_genes() -> None:
t = build_table("CYP2D6", ACT_ROWS, ACT_ALLELES)
assert phenotype(t, ("*1", "*4")).phenotype == "Intermediate Metabolizer"
r = phenotype(t, ("*1x2", "*1")) # duplication: 2 × activity
assert r.phenotype == "Ultrarapid Metabolizer" and r.activity_score == 3.0
assert phenotype(t, ("*1", "*5")).phenotype == "Intermediate Metabolizer" # *5 = whole-gene deletion
r = phenotype(t, ("*1", "*10")) # key absent → largest tabulated total ≤ 1.25
assert r.phenotype == "Intermediate Metabolizer" and r.activity_score == 1.25
r = phenotype(t, ("*1", "*99")) # PGX-024: unknown allele never crashes
assert r.phenotype == "Indeterminate" and "phenotype_not_in_guideline" in r.flags
def test_function_genes_and_disagreement() -> None: # PGX-025
t = build_table("CYP2C19", FUNC_ROWS, FUNC_ALLELES)
assert phenotype(t, ("*2", "*1")).phenotype == "Intermediate Metabolizer"
res, per = gene_phenotype(t, [("*1", "*1"), ("*1", "*2")])
assert res.phenotype == "Indeterminate" and per == ["Normal Metabolizer", "Intermediate Metabolizer"]
res, per = gene_phenotype(t, [("*1", "*2"), ("*2", "*1")])
assert res.phenotype == "Intermediate Metabolizer" and per == []
+114
View File
@@ -0,0 +1,114 @@
"""Star-allele calling (spec PGX-012 … PGX-017)."""
from __future__ import annotations
from pathlib import Path
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
def test_cn_only_genes() -> None: # PGX-006
from simurg.pipeline.pgx_star import cn_only_gene
assert [cn_only_gene("s", "s", "GSTM1", c)[0]["genotype"] for c in (0, 1, 2, 3, 5, None)] == [
"*0/*0", "*1/*0", "*1/*1", "*1x2/*1", "*1xN/*1", ""] # fmt: skip
def test_variant_based_gene() -> None: # PGX-021: per-variant notation, diplotype by lowest activity
from simurg.pipeline.pgx_star import StarOptions, call_variant_gene
variants = [("1", 100, "C", "T"), ("1", 200, "G", "A"), ("1", 300, "A", "G")]
carries = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.int8)
m = GeneModel("DPYD", variants, [("h0", "rs1"), ("h1", "rs2"), ("h2", "rs3")],
["Reference", "c.A", "c.B", "c.C"], carries, np.array([0.9, 0.05, 0.03, 0.02]), None, "Reference",
"variant_based", {"Reference": 1.0, "c.A": 1.0, "c.B": 0.0, "c.C": 0.5},
{"Reference": "Normal function", "c.A": "Normal function", "c.B": "No function",
"c.C": "Decreased function"}) # fmt: skip
recs = {("1", 100): [{"id": "p1", "ref": "C", "alts": ["T"], "gt": "0/1", "gs": "0.5", "baf": "0.5"}],
("1", 200): [{"id": "p2", "ref": "G", "alts": ["A"], "gt": "0/1", "gs": "0.5", "baf": "0.5"}],
("1", 300): [{"id": "p3", "ref": "A", "alts": ["G"], "gt": "0/1", "gs": "0.5", "baf": "0.5"}]} # fmt: skip
gj, rows, sup, _, _ = call_variant_gene("s", "s", m, recs, set(), StarOptions([], Path("x"), Path("y")))
assert gj is not None and gj["genotype"] == "rs1 C/T; rs2 G/A; rs3 A/G"
assert gj["feasible_solutions"] == ["c.B/c.C"] # three carried: the two of lowest activity
assert rows[0]["call_type"] == "variant" and len(sup) == 3
recs = {("1", 100): [{"id": "p1", "ref": "C", "alts": ["T"], "gt": "0/0", "gs": "0.5", "baf": "0"}]}
gj, *_ = call_variant_gene("s", "s", m, recs, set(), StarOptions([], Path("x"), Path("y")))
assert gj is not None and gj["genotype"] == "Reference" and gj["feasible_solutions"] == ["Reference/Reference"]
def test_cyp2d6_structures() -> None: # PGX-013 CN rules
from simurg.pgx.star import Cyp2d6Structure, cyp2d6_structure, structure_candidates
assert cyp2d6_structure(3, 2, 2) == Cyp2d6Structure(2, 0, 1, 0) # *68 unit (NA12878 *3/*68+*4)
assert cyp2d6_structure(3, 3, 2) == Cyp2d6Structure(2, 1, 0, 0) # *36 unit
assert cyp2d6_structure(1, 2, 2) == Cyp2d6Structure(1, 0, 0, 1) # *13 hybrid
assert cyp2d6_structure(2, 2, 2) == Cyp2d6Structure(2, 0, 0, 0)
m = _model()
m.haplotypes = ["*1", "*4", "*10", "*2", "*5"]
names = {n for _, n in structure_candidates(m, Cyp2d6Structure(2, 0, 1, 0))}
assert ("*1", "*68+*4") in names and ("*68+*1", "*2") in names
names = {n for _, n in structure_candidates(m, Cyp2d6Structure(2, 1, 0, 0))}
assert ("*36+*10", "*4") in names and all("*36+*10" in n for n in names)
+3 -4
View File
@@ -167,10 +167,9 @@ def test_absent_addresses_are_skipped() -> None: # QC-008, K-021: two bead-pool
assert q.missing["staining_green"] == ["biotin_bkg"] assert q.missing["staining_green"] == ["biotin_bkg"]
def test_methylation_is_v2() -> None: def test_methylation_metric_set() -> None: # QC-006: methylation metrics are computed (v2); missing ones are NaN
with pytest.raises(SimurgError) as e: q = compute_control_metrics(_beads(), np.ones(21), np.ones(21), "methylation")
compute_control_metrics(_beads(), np.ones(21), np.ones(21), "methylation") assert len(q.values) == 21 and "stringency" not in q.values and "bs_conv_2" in q.missing
assert e.value.code == "E_QC_METHYLATION"
# ------------------------------------------------------------------------------------------------ thresholds # ------------------------------------------------------------------------------------------------ thresholds
+88
View File
@@ -0,0 +1,88 @@
"""ArrayTrain cluster training (plan §12)."""
from __future__ import annotations
from pathlib import Path
import numpy as np
from simurg.formats.egt import read_egt, write_egt
from simurg.train.clusters import fit_block, impute_missing, learn_relations
def _block(rng: np.random.Generator, n_loci: int, s: int, freqs: np.ndarray) -> tuple[np.ndarray, ...]:
centres = np.array([0.05, 0.5, 0.95])
theta = np.empty((n_loci, s), np.float32)
r = np.empty((n_loci, s), np.float32)
truth = np.empty((n_loci, s), np.int64)
for i in range(n_loci):
p = freqs[i]
g = rng.binomial(2, p, s)
truth[i] = g
theta[i] = np.clip(centres[g] + rng.normal(0, 0.025, s), 0, 1)
r[i] = np.where(g == 1, 1.0, 0.85) + rng.normal(0, 0.08, s)
return theta, r, truth
def test_fit_three_clusters() -> None:
rng = np.random.default_rng(1)
theta, r, _ = _block(rng, 40, 200, np.full(40, 0.4))
fit = fit_block(theta, r, np.ones_like(theta, bool), np.zeros(40, bool))
assert (fit.n > 0).all()
assert np.allclose(fit.theta_mean.mean(axis=0), [0.05, 0.5, 0.95], atol=0.02)
assert np.all(fit.n.sum(axis=1) >= 190) # nearly every sample assigned
assert fit.train_score.min() > 0.7 and fit.cluster_sep.min() > 0.9
def test_monomorphic_and_haploid() -> None:
rng = np.random.default_rng(2)
theta = np.clip(0.3 + rng.normal(0, 0.02, (2, 150)), 0, 1).astype(np.float32) # one cluster at θ 0.3
r = np.ones_like(theta)
fit = fit_block(theta, r, np.ones_like(theta, bool), np.zeros(2, bool))
assert (fit.n[:, 1] == 0).all() and (fit.n[:, 0] > 0).all() # a lone cluster is homozygous (AA by θ < 0.5)
theta, r, _ = _block(rng, 3, 100, np.full(3, 0.5))
theta = np.where(np.abs(theta - 0.5) < 0.2, 0.95, theta).astype(np.float32) # haploid: no hets
fit = fit_block(theta, r, np.ones_like(theta, bool), np.ones(3, bool))
assert (fit.n[:, 1] == 0).all()
def test_impute_missing_clusters() -> None:
rng = np.random.default_rng(3)
theta, r, _ = _block(rng, 60, 200, np.full(60, 0.45))
rare, r2, _ = _block(rng, 5, 200, np.full(5, 0.0)) # only AA observed
fit = fit_block(np.vstack([theta, rare]), np.vstack([r, r2]), np.ones((65, 200), bool), np.zeros(65, bool))
rel = learn_relations(fit)
impute_missing(fit, rel)
assert (fit.n[60:, 1:] == 0).all()
assert np.all(np.abs(fit.theta_mean[60:, 1] - 0.5) < 0.08) and np.all(fit.theta_mean[60:, 2] > 0.85)
assert np.isfinite(fit.r_mean).all() and (fit.theta_dev > 0).all()
def test_egt_round_trip(tmp_path: Path) -> None: # plan §12 step 5
k = 4
n = np.array([[10, 5, 0]] * k, np.int32)
p = tmp_path / "t.egt"
write_egt(p, [f"rs{i}" for i in range(k)], np.arange(k, dtype=np.int32), n,
np.tile(np.float32([0.1, 0.5, 0.9]), (k, 1)), np.full((k, 3), 0.03, np.float32),
np.ones((k, 3), np.float32), np.full((k, 3), 0.1, np.float32), np.full(k, 0.5, np.float32),
np.full(k, 0.8, np.float32), "M.bpm") # fmt: skip
e = read_egt(p)
assert e.data_block_version == 9 and e.extra_per_record == 15 and e.names == [f"rs{i}" for i in range(k)]
assert (e.n == n).all() and np.allclose(e.gentrain_score, 0.8) and np.allclose(e.theta_mean[0], [0.1, 0.5, 0.9])
def test_compressed_and_two_cluster_labelling() -> None:
"""Compressed loci (BB at θ ≈ 0.45) and AB + BB two-cluster loci are labelled by HWE + position, not by fixed
θ windows (the GSE165845 failure modes, docs/validation/V2_arraytrain.md)."""
rng = np.random.default_rng(4)
s = 200
g = rng.binomial(2, 0.6, s) # compressed: AA 0.05, AB 0.25, BB 0.45
theta_c = np.clip(np.array([0.05, 0.25, 0.45])[g] + rng.normal(0, 0.02, s), 0, 1)
g2 = rng.binomial(2, 0.9, s)
g2[g2 == 0] = 1 # rare allele never homozygous: two clusters, AB (0.5) + BB (0.95)
theta_2 = np.clip(np.array([0.05, 0.5, 0.95])[g2] + rng.normal(0, 0.025, s), 0, 1)
theta = np.vstack([theta_c, theta_2]).astype(np.float32)
fit = fit_block(theta, np.ones_like(theta), np.ones_like(theta, bool), np.zeros(2, bool))
assert (fit.n[0] > 0).all() and np.allclose(fit.theta_mean[0], [0.05, 0.25, 0.45], atol=0.02)
assert fit.n[1, 0] == 0 and fit.n[1, 1] > 0 and fit.n[1, 2] > 0
assert abs(fit.n[1, 1] - (g2 == 1).sum()) <= 3
Generated
+737
View File
@@ -0,0 +1,737 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" },
{ url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" },
{ url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" },
{ url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" },
{ url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" },
{ url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" },
{ url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" },
{ url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" },
{ url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" },
{ url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" },
{ url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" },
{ url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" },
{ url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" },
{ url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" },
{ url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" },
{ url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" },
{ url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" },
{ url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" },
{ url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" },
{ url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" },
{ url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" },
{ url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" },
{ url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" },
{ url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" },
{ url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" },
{ url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" },
{ url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" },
{ url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" },
{ url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" },
{ url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "hypothesis"
version = "6.168.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" },
{ url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" },
{ url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" },
{ url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" },
{ url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" },
{ url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" },
{ url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" },
{ url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" },
{ url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" },
{ url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" },
{ url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" },
{ url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" },
{ url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" },
{ url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" },
{ url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" },
{ url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" },
{ url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" },
{ url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" },
{ url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" },
{ url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" },
{ url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" },
{ url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" },
{ url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" },
{ url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" },
{ url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" },
{ url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" },
{ url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" },
{ url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" },
{ url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" },
{ url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" },
{ url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" },
{ url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" },
{ url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" },
{ url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" },
{ url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" },
{ url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" },
{ url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" },
{ url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" },
{ url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" },
{ url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" },
{ url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "librt"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" },
{ url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" },
{ url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" },
{ url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" },
{ url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" },
{ url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" },
{ url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" },
{ url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" },
{ url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" },
{ url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" },
{ url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" },
{ url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" },
{ url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
{ url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
{ url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
{ url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
{ url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
{ url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
{ url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" },
{ url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" },
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
{ url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
{ url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
{ url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
{ url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
{ url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
{ url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
{ url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
{ url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" },
{ url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" },
{ url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
{ url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
{ url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
{ url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
{ url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
{ url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
{ url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
{ url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" },
{ url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" },
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" },
{ url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" },
{ url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" },
{ url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" },
{ url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" },
{ url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
{ url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
{ url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
{ url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
{ url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" },
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
{ url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
{ url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "py-cpuinfo2"
version = "10.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/97/a8b1ddada14c8280a047c0746f95cb05d94a31b1a331cea22bcdc2b2a82d/py_cpuinfo2-10.1.1.tar.gz", hash = "sha256:7861133863663f16e06eca63b12904ef100b5760415e92372dac0162799a4771", size = 100840, upload-time = "2026-03-25T21:49:40.797Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/0a/ba69d2dde1ae12ef1d389ea5a216384c5ff6ef7a1e7a48d1e9b6686f6790/py_cpuinfo2-10.1.1-py3-none-any.whl", hash = "sha256:adc53396bfb206e6498d078ec2ab407f85799ecd819584ac36a8f80a2d4d762d", size = 23791, upload-time = "2026-03-25T21:49:39.574Z" },
]
[[package]]
name = "pyarrow"
version = "25.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" },
{ url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" },
{ url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" },
{ url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" },
{ url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" },
{ url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" },
{ url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" },
{ url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" },
{ url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" },
{ url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" },
{ url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" },
{ url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" },
{ url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
{ url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
{ url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
{ url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
{ url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
{ url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
{ url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
{ url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
{ url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-benchmark"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "py-cpuinfo2" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/8f/83a15e40dbc34a580ee56eb56983cae5394c6e94d50cf28fe268e457be25/pytest_benchmark-5.3.0.tar.gz", hash = "sha256:358444d4e89be901ee2b6404fb043ac3d7684002ad7f3563cc153fca6339c965", size = 375410, upload-time = "2026-08-23T17:45:08.891Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/42/7e80f7cfa191e0a766d1de99b4661847415ad5db34f8209d81fd42175b59/pytest_benchmark-5.3.0-py3-none-any.whl", hash = "sha256:920ab1dfcffa718d49aa15ba144c7e357bda59216a0dc308016cc1c7236f719d", size = 48401, upload-time = "2026-08-23T17:45:07.094Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.16.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "simurg"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
{ name = "openpyxl" },
{ name = "pyarrow" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "hypothesis" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-benchmark" },
{ name = "ruff" },
{ name = "types-pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "openpyxl", specifier = ">=3.1" },
{ name = "pyarrow", specifier = ">=15" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=4" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "rich", specifier = ">=13" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
{ name = "typer", specifier = ">=0.12" },
{ name = "types-pyyaml", marker = "extra == 'dev'" },
]
provides-extras = ["dev"]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "typer"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260906"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]