Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af39d0ee6a | ||
|
|
077619a6f1 | ||
|
|
6d22b5e19b | ||
|
|
1eb7ea8154 | ||
|
|
adad0efb08 | ||
|
|
7cfb0fd962 | ||
|
|
cfae926fbc | ||
|
|
3fa97df61e | ||
|
|
151723d5e1 | ||
|
|
d60a1d5180 | ||
|
|
ba8454e2c8 | ||
|
|
1cb7b59559 | ||
|
|
ed5e92b859 | ||
|
|
9a39ae62fe | ||
|
|
b96ae21c70 | ||
|
|
9a02f39d01 | ||
|
|
e3f784859b | ||
|
|
f885593e32 | ||
|
|
08d7fe6840 | ||
|
|
feb1d993b5 | ||
|
|
0c5dd03ca8 | ||
|
|
c3e0681b6e | ||
|
|
518bfbe059 | ||
|
|
924b6bb4b7 | ||
|
|
da876b628e | ||
|
|
d5ab45886c | ||
|
|
fa36e550c9 | ||
|
|
803f4aaec3 | ||
|
|
6bc42e53b7 | ||
|
|
98e16cf501 | ||
|
|
98f981b59e | ||
|
|
6ad0c53664 | ||
|
|
1e41acef27 | ||
|
|
b8e3cf233a | ||
|
|
b6e8177e5a | ||
|
|
0bf5576049 | ||
|
|
615a6139bf | ||
|
|
70001a0d6c | ||
|
|
edb59e4ee2 | ||
|
|
5164124438 | ||
|
|
6aebb31991 | ||
|
|
3a816a1c44 | ||
|
|
1aa9459525 | ||
|
|
03587b2b0a | ||
|
|
da4a67b59b | ||
|
|
3eb51639cc |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
# vendor / large data — never commit
|
||||
data/
|
||||
/data/
|
||||
refs/
|
||||
_phase0_archive/
|
||||
*.idat
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# 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)
|
||||
|
||||
**Packaging (P10).** `scripts/build_dist.sh` (sdist, pure wheel, manylinux_2_28 kernel wheels for x86_64 and
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# SimurgArray container: the `simurg` CLI with the compiled kernels.
|
||||
# docker build -t simurg:1.1.0 .
|
||||
# docker run --rm -v "$PWD":/work simurg:1.1.0 genotype --bpm M.bpm --egt C.egt --idats idats --out run/gtc
|
||||
# docker build -t simurg:2.0.0 .
|
||||
# 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.
|
||||
ARG PYTHON=3.12
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# SimurgArray
|
||||
|
||||
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_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
|
||||
|
||||
**v1.1.0** (2026-09-28) — see `CHANGELOG.md`. IDAT → normalization → GenCall-equivalent genotype calling → 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/`):
|
||||
**v2.0.0** (2026-09-30) — see `CHANGELOG.md`. v1: IDAT → normalization → GenCall-equivalent genotype calling →
|
||||
GTC v5 → sample/control QC with an HTML report → plus-strand VCF/BCF → PLINK 1/2, bedGraph, final report; plus a
|
||||
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 |
|
||||
|---|---|
|
||||
@@ -19,13 +21,17 @@ differential-testing harness. Validation (`docs/validation/`):
|
||||
| GSA demo vs gtc2vcf, GTCtoVCF, plink2 | zero unexplained differences |
|
||||
| 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 |
|
||||
| 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`,
|
||||
`gtc-to-vcf`, `export plink|bedgraph|finalreport|locus-summary`, `compare`. **User guide: `docs/user_guide.md`**;
|
||||
worked example: `examples/gsa_demo.sh`. P9 (performance) is done: optional Rust kernels, byte-identical outputs,
|
||||
~80 GSA samples/min per server core (`docs/validation/P9.md`). P10 (packaging) is done: wheels, Docker image and
|
||||
conda recipes, verified on fresh x86_64 and aarch64 machines (`docs/validation/P10.md`). Next (plan §18): v2 modules
|
||||
(cluster training, CN/LOH, PGx).
|
||||
`gtc-to-vcf`, `export plink|bedgraph|finalreport|locus-summary`, `compare`, `train`, `cn build-model|call|annotate`,
|
||||
`pgx db build|coverage|copy-number|star-allele|phenotype`, `methyl qc|convert-manifest`. **User guide:
|
||||
`docs/user_guide.md`**; worked example: `examples/gsa_demo.sh`. Optional Rust kernels (byte-identical outputs,
|
||||
~80 GSA samples/min per server core, `docs/validation/P9.md`); wheels, Docker image and conda recipes
|
||||
(`docs/validation/P10.md`).
|
||||
|
||||
## Quick start
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
- **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)
|
||||
@@ -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
|
||||
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].**
|
||||
- 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-039 | Batch scale | §10, §19 | v2 | D-9, 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-002 | Genotype every region [EMU] | §11 | v2 | B-23 |
|
||||
| PCN-003 | Inputs | §11 | v2 | A-G37 |
|
||||
|
||||
@@ -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) |
|
||||
| [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` |
|
||||
| [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 |
|
||||
| [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)
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ Plan §20 lists the open questions; this file records what the implementation ha
|
||||
|
||||
| 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-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 |
|
||||
|
||||
+37
-1
@@ -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_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_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_PARSE` | error | --config file is not valid YAML | CLI-019 |
|
||||
| `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_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_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_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_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_MISSING` | error | QC config file does not exist | 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_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_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_NO_DATASETS` | error | No QC dataset folder found | QC-041 |
|
||||
| `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_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_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 |
|
||||
| `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_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 |
|
||||
@@ -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_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_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_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_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 |
|
||||
|
||||
+125
-7
@@ -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
|
||||
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`.
|
||||
|
||||
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) ·
|
||||
[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):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**Docker** (image ≈ 550 MB, kernels included, runs as a non-root user; data stay in the mounted folder):
|
||||
|
||||
```bash
|
||||
docker build -t simurg:1.1.0 .
|
||||
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/work simurg:1.1.0 \
|
||||
docker build -t simurg:2.0.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
|
||||
```
|
||||
|
||||
@@ -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 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 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 |
|
||||
|
||||
Global logging options on every command: `--quiet`, `--log-level`, `--log-json` (JSON Lines on stderr), `--debug`.
|
||||
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
|
||||
|
||||
* `--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 % |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
## Limitations
|
||||
|
||||
* Calls need an existing EGT cluster file; cluster training (v2), copy number / LOH / mosaicism and PGx
|
||||
diplotyping are not in v1.0.
|
||||
* ArrayTrain was validated on one chip and cohort (GSA v1, GSE165845). CN calling finds rare events, but common
|
||||
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
|
||||
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
|
||||
`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).
|
||||
* Methylation arrays: control-probe QC only.
|
||||
* Methylation arrays: control-probe QC, detection and β values only (no differential analysis).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
Executable
+32
@@ -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"
|
||||
@@ -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) ==="
|
||||
@@ -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) ==="
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Executable
+50
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Executable
+29
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "2.0.0"
|
||||
PRODUCT_NAME = "SimurgArray"
|
||||
@@ -666,6 +666,424 @@ def qc_controls_cmd(
|
||||
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(
|
||||
command: str,
|
||||
out: Path,
|
||||
|
||||
@@ -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.
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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])))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_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("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_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"),
|
||||
@@ -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_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"),
|
||||
# --- 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"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -233,3 +233,52 @@ def _parse_body(
|
||||
trailer_floats=trailer,
|
||||
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())
|
||||
@@ -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
|
||||
@@ -15,7 +15,12 @@ from pathlib import Path
|
||||
from simurg.core.errors import SimurgError
|
||||
|
||||
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 = (
|
||||
"Sample_ID",
|
||||
"Sample_Name",
|
||||
@@ -246,7 +251,8 @@ def parse_samplesheet(path: str | Path, *, on_duplicate_sample: str = "error") -
|
||||
raise _err(
|
||||
"E_SHEET_NO_KEY_COLUMNS",
|
||||
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])}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -36,6 +36,7 @@ from simurg.qc.controls import (
|
||||
ControlBead,
|
||||
ControlQC,
|
||||
beads_from_definitions,
|
||||
canonical_id,
|
||||
compare_definitions,
|
||||
compute_control_metrics,
|
||||
control_intensities,
|
||||
@@ -123,9 +124,10 @@ def control_sample(
|
||||
beads: Sequence[ControlBead],
|
||||
bg_offset: float,
|
||||
sheet_values: Mapping[str, str] | None = None,
|
||||
array_type: str = "genotyping",
|
||||
) -> ControlSample:
|
||||
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)
|
||||
|
||||
|
||||
@@ -250,8 +252,8 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
|
||||
|
||||
rm = RunManifest(command="qc controls", argv=opts.argv)
|
||||
rep.run_id = rm.run_id
|
||||
if opts.array_type == "methylation":
|
||||
raise SimurgError("E_QC_METHYLATION", "methylation control metrics are planned for v2 (QC-006)")
|
||||
if opts.array_type == "methylation" and opts.csv is None:
|
||||
raise SimurgError("E_NO_INPUT", "methylation control QC needs the methylation CSV manifest (--csv)")
|
||||
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)")
|
||||
if opts.bg_offset < 0:
|
||||
@@ -261,8 +263,15 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
|
||||
names = (COMPAT_RAW, COMPAT_QC) if opts.compat else (CONTROLS_RAW, CONTROLS_QC)
|
||||
targets = [opts.out / n for n in names]
|
||||
check_outputs_absent(targets, force=opts.force) # QC-014: other files in the folder are fine
|
||||
manifest = read_bpm(opts.bpm) if opts.bpm else None
|
||||
beads = load_control_beads(manifest, opts.csv, rep)
|
||||
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
|
||||
beads = load_control_beads(manifest, opts.csv, rep)
|
||||
if not beads:
|
||||
raise SimurgError("E_BPM_FORMAT", "the manifest has no control definitions")
|
||||
samples: list[ControlSample] = []
|
||||
@@ -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
|
||||
grn, red = read_idat(s.pair.grn), read_idat(s.pair.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
|
||||
summary = opts.out / "sample_summary.tsv"
|
||||
if summary.exists():
|
||||
@@ -282,7 +293,9 @@ def run_qc_controls(opts: QcControlsOptions, rep: Reporter) -> int:
|
||||
if ids ^ mine:
|
||||
rep.warn("W_QC_UNMATCHED_SAMPLES", f"{len(ids ^ mine)} sample ID(s) differ from {summary}")
|
||||
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:
|
||||
rm.add_output(p, "simurg.controls/1")
|
||||
rm.summary = {"samples": len(samples)}
|
||||
|
||||
@@ -593,8 +593,6 @@ def run_qc_report(opts: QcReportOptions, rep: Reporter) -> int:
|
||||
"Genotyping and methylation datasets cannot be mixed: "
|
||||
+ "; ".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)
|
||||
if total > 20000:
|
||||
rep.warn("W_QC_TOO_FEW", f"{total} samples in total: the report may be slow to open (QC-057)")
|
||||
|
||||
@@ -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
|
||||
@@ -51,6 +51,20 @@ canonical:
|
||||
bs conversion i u3: bs1_U3
|
||||
bs conversion i u4: bs1_U4
|
||||
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-2: bs2_2
|
||||
bs conversion ii-3: bs2_3
|
||||
|
||||
@@ -71,6 +71,10 @@ METRICS: tuple[Metric, ...] = (
|
||||
Metric("bs_conv_2", "bisulfiteConversionII", 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
|
||||
COMPAT_METRIC_NAMES = {
|
||||
"restoration": "RestorationQC",
|
||||
@@ -223,14 +227,12 @@ def compute_control_metrics(
|
||||
red: npt.NDArray[np.float64],
|
||||
array_type: str = "genotyping",
|
||||
bg_offset: float = DEFAULT_BG_OFFSET,
|
||||
bs_conv_red_bg_set: str = "U3-U5",
|
||||
) -> ControlQC:
|
||||
"""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)
|
||||
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]] = {
|
||||
# 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),
|
||||
@@ -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")),
|
||||
"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")),
|
||||
# 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={})
|
||||
for m in metrics_for(array_type):
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""ArrayTrain: cluster-file training (plan §12)."""
|
||||
@@ -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)
|
||||
+22
-15
@@ -178,18 +178,24 @@ def test_info_and_extra_format_match_gtc2vcf(tmp_path: Path) -> None: # OUT-012
|
||||
) # fmt: skip
|
||||
assert res.exit_code == 0, res.output
|
||||
env = dict(os.environ, BCFTOOLS_PLUGINS=str(plugin.parent))
|
||||
g2v = subprocess.run(
|
||||
[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"],
|
||||
capture_output=True, text=True, env=env, check=True,
|
||||
).stdout # fmt: skip
|
||||
ref = {}
|
||||
for line in g2v.splitlines():
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
c = line.split("\t")
|
||||
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)))
|
||||
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(GSA_CSV), "-e", str(GSA_EGT), "-f", str(FASTA), "-g", str(gtcs), "-O", "v"],
|
||||
stdout=fh, env=env, check=True,
|
||||
) # fmt: skip
|
||||
keys = ("X", "Y", "NORMX", "NORMY", "THETA", "R")
|
||||
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("#"):
|
||||
continue
|
||||
c = line.rstrip("\n").split("\t")
|
||||
info = dict(kv.split("=", 1) for kv in c[7].split(";") if "=" in kv)
|
||||
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:
|
||||
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
|
||||
info = dict(kv.split("=", 1) for kv in c[7].split(";"))
|
||||
fmt = dict(zip(c[8].split(":"), c[9].split(":"), strict=True))
|
||||
ri, rf = ref[c[2]]
|
||||
assert info["ASSAY_TYPE"] == ri["ASSAY_TYPE"]
|
||||
assert close(info["GENTRAIN"], ri["GenTrain_Score"]) and close(info["CLUSTER_SEP"], ri["Cluster_Sep"])
|
||||
at, gt_score, sep, *vals = ref[c[2]]
|
||||
rf = dict(zip(keys, vals, strict=True))
|
||||
assert info["ASSAY_TYPE"] == at
|
||||
assert close(info["GENTRAIN"], gt_score) and close(info["CLUSTER_SEP"], sep)
|
||||
for k in ("X", "Y", "NORMX", "NORMY"):
|
||||
assert close(fmt[k], rf[k]), (c[2], k, fmt[k], rf[k])
|
||||
if fmt["NORMX"] == "0" and fmt["NORMY"] == "0":
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
|
||||
def test_methylation_is_v2() -> None:
|
||||
with pytest.raises(SimurgError) as e:
|
||||
compute_control_metrics(_beads(), np.ones(21), np.ones(21), "methylation")
|
||||
assert e.value.code == "E_QC_METHYLATION"
|
||||
def test_methylation_metric_set() -> None: # QC-006: methylation metrics are computed (v2); missing ones are NaN
|
||||
q = 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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ thresholds
|
||||
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user