P6: gtc-to-vcf (VCF/BCF + indexes), harmonisation, exports (plink, bedgraph, finalreport)

- formats/fasta: plain/BGZF FASTA, .fai/.gzi built identical to htslib, index check, build detection,
  contig aliases, mmap genome cache (IN-060..064)
- formats/bgzf, formats/bcf: pure-Python BGZF, tabix .tbi, BCF 2.2 and CSI (OUT-010, OUT-038, OUT-040)
- harmonize/sites: plus-strand mapping with audit rows, indel resolution from SourceSeq (port of GTCtoVCF,
  Apache-2.0) and left normalisation (OUT-014..021, OUT-030, OUT-042)
- harmonize/combine: OUT-031 genotype combination (ploidy-aware, Infinium I/II signal classes, OUT-037)
- pipeline/vcf: `simurg gtc-to-vcf` (per-sample VCF.gz+tbi, --merge cohort BCF+csi, audit parquet)
- pipeline/export: `simurg export bedgraph|finalreport|plink|locus-summary` (OUT-043, OUT-050..052, OUT-071)
- validation: 36 GSA samples vs gtc2vcf: 23.66 M identical calls, 0 unexplained differences; PLINK identical
  to plink2 on 637,397 variants; bcftools validates VCF/BCF and our indexes answer region queries correctly
- fetch_data.sh p6 (Ensembl GRCh38 with checksum), make_oracle_vcf.sh, validate_p6_vcf.py; K-022/K-023

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01814GKVzZVL3FuF3DeiZXhv
This commit is contained in:
kemalandClaude Opus 5.5 committed 2026-09-28 12:17:53 +01:00
1 parent 60a05d5048
commit b56fdebaaf
19 files changed
+3181 -9

No files matched your search

+2
View File
@@ -4,6 +4,8 @@ Plan §20 lists the open questions; this file records what the implementation ha
| ID | Date | Topic | Finding | Status |
|---|---|---|---|---|
| K-023 | 2026-09-28 | P6 options not yet implemented | `--auxiliary-loci` (OUT-035), `--info-fields`/`--extra-format` (OUT-012; GENTRAIN/CLUSTER_SEP would also need the EGT), `--merge-snv-indel` (OUT-021), PLINK 2 `.pgen`. Everything else of CLI-022/CLI-023 works. | open |
| K-022 | 2026-09-28 | VCF oracles' limits (GSA v4 PGx, GRCh38) | gtc2vcf leaves 144 indel probes unresolved (`REF=I`/`D`) that SimurgArray resolves from `SourceSeq`; both leave 49 indels unresolved. GTCtoVCF (python-2.7 era) runs under Python 3 only with two local patches (pyvcf3 `Format` field; skip instead of crash on 3 CYP2D6 indels whose `SourceSeq` has the dash on the right, `[ALLELE/-]`, which SimurgArray handles), and needs the alt-contig loci filtered when the FASTA has no alts. Neither oracle applies ploidy (males' X/Y/MT hets stay diploid). | documented |
| K-021 | 2026-09-28 | Control metrics on legacy Omni2.5 IDATs | (1) The Omni2.5 manifest lists two bead addresses per control (e.g. 27630314 and 127630314, two bead-pool versions); only one is on a given chip. Absent addresses were NaN and poisoned the bead mean → 1794/2141 samples falsely flagged on both staining metrics. Absent addresses are now skipped. (2) 1290 samples have Biotin (Bgnd) green = 0 exactly (background-subtracted), so staining green is signal/0; QC-008 now defines that as +Inf (pass) and only 0/0 as NaN (flag). | resolved (spec QC-008 amended) |
| K-020 | 2026-09-28 | QC report scope (P5b) | Implemented: views A–E, linked selection, thresholds modal (apply/download/upload YAML), chip/plate heatmaps, trend table and Tukey box plots, PCA card, xlsx/csv table, privacy options, `--split-by dataset`. Not yet: client-side xlsx export (CSV only), density mode for > 12 000 points (WebGL scatter is used), `--split-by plate|run`, X/Y median R for the sex card (the summary does not carry them; the card shows the sex check instead), methylation datasets (v2). | open (deviations listed) |
| K-019 | 2026-09-28 | GSA demo relatedness | The 36-sample GSA v4 PGx demo set contains duplicate/replicate pairs (KING φ ≈ 0.50, concordance ≥ 0.9999) and first-degree relatives (φ ≈ 0.25); without a pedigree they are flagged `duplicate_of:`/`unexpected_relatedness` as designed. Useful as a small relatedness fixture. | documented |
+21
View File
@@ -4,8 +4,10 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| Code | Level | Meaning | Spec |
|---|---|---|---|
| `E_AUX_INVALID` | error | Auxiliary-loci record is invalid | OUT-035 |
| `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_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 |
@@ -13,6 +15,8 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `E_DUP_IDAT` | error | Several IDAT pairs resolve to the same sample | IN-022 |
| `E_EGT_LOAD` | error | Cluster (EGT) file cannot be loaded | IN-044 |
| `E_EGT_MANIFEST_MISMATCH` | error | Cluster file lacks loci of the manifest | IN-043 |
| `E_FAI_MISMATCH` | error | FASTA index does not match the FASTA | IN-060 |
| `E_FASTA_FORMAT` | error | FASTA file is malformed | IN-060 |
| `E_FILE_NOT_FOUND` | error | Input file or folder does not exist | CLI-016 |
| `E_FILE_UNREADABLE` | error | Input file cannot be read | CLI-016 |
| `E_GTC_CORRUPT` | warning | GTC file failed validation; sample skipped | IN-032 |
@@ -62,13 +66,30 @@ Generated from `src/simurg/core/errors.py` — do not edit by hand.
| `W_IDAT_NAME_MISMATCH` | warning | IDAT barcode/position disagrees with its file name | IN-023 |
| `W_IDAT_NO_SCAN_TIME` | warning | IDAT has no scan entry in its run info | IN-024 |
| `W_IDAT_UNPAIRED` | warning | IDAT channel file without its partner | IN-021 |
| `W_INDEL_PARTIAL_MATCH` | warning | Indel source sequence matches the genome only partially | OUT-019 |
| `W_INF1_NC_CONFLICT` | warning | Infinium I no-call conflicts with other assays | OUT-037 |
| `W_LARGE_OUTPUT` | warning | Output will be very large | EDGE-036 |
| `W_MANIFEST_NO_REFSTRAND` | warning | Manifest lacks RefStrand annotation | IN-040 |
| `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_NO_BGZIP` | warning | Uncompressed VCF written; downstream commands need bgzip + index | OUT-010 |
| `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 |
| `W_QC_TOO_FEW` | warning | Too few samples or sites for a cohort QC statistic | QC-026 |
| `W_QC_UNMATCHED_SAMPLES` | warning | Sample IDs present in one QC file but not the other | QC-040 |
| `W_REF_NOT_QUERIED` | warning | The reference allele is not among the probe's alleles | OUT-015 |
| `W_SHEET_BARCODE_REPAIRED` | warning | Barcode written as a decimal was repaired | IN-007 |
| `W_SHEET_EMPTY_COLUMN` | warning | Columns with empty headers are ignored | IN-004 |
| `W_SHEET_ROOTFOLDER_OVERRIDDEN` | warning | CLI folder overrides the sheet RootFolder | IN-008 |
| `W_SHEET_SEX_CONFLICT` | warning | Reported-sex columns disagree | IN-014 |
| `W_SKIP_AMBIGUOUS` | warning | Ambiguous-strand locus dropped by --drop-ambiguous | OUT-042 |
| `W_SKIP_CONTIG` | warning | Manifest chromosome absent from the FASTA | OUT-019 |
| `W_SKIP_EXCLUDED` | warning | Locus excluded by --exclude-loci/--include-loci | OUT-019 |
| `W_SKIP_INDEL_CONTEXT` | warning | Indel sequence cannot be determined (no CSV SourceSeq) | OUT-019 |
| `W_SKIP_INDEL_REF` | warning | Indel reference allele cannot be determined from the FASTA | OUT-019 |
| `W_SKIP_INTENSITY_ONLY` | warning | Intensity-only locus was skipped | OUT-019 |
| `W_SKIP_NO_STRAND` | warning | Locus has neither RefStrand nor a sequence to align | OUT-042 |
| `W_SKIP_UNMAPPED` | warning | Locus without a genomic position was skipped | OUT-019 |
| `W_UNKNOWN_LOCI` | warning | Names in a locus list are not in the manifest | OUT-036 |
+63
View File
@@ -0,0 +1,63 @@
# Phase P6 validation — outputs (VCF/BCF, PLINK, bedGraph, final report)
Date: 2026-09-28. All work on the Raspberry Pi (no cloud cost). Data: the 36 GSA v4 PGx oracle GTCs (P4, byte-identical
to gtc2vcf's), the GSA BPM + CSV manifest, and the Ensembl GRCh38 primary assembly (release 110, `fetch_data.sh p6`).
## Delivered
| Item | Module |
|---|---|
| FASTA access: plain/BGZF, `.fai`/`.gzi` read and built (identical to htslib's), index check, build detection, contig aliases, mmap genome cache | `formats/fasta.py` |
| BGZF writer, tabix `.tbi`, BCF 2.2 encoder, CSI index (pure Python) | `formats/bgzf.py`, `formats/bcf.py` |
| Plus-strand mapping with audit row per locus; indel resolution from `SourceSeq` (context score), left normalisation | `harmonize/sites.py` |
| Site grouping and the OUT-031 genotype-combination algorithm (ploidy-aware, Infinium I/II signal classes) | `harmonize/combine.py` |
| `simurg gtc-to-vcf` (per-sample VCF.gz + .tbi, `--merge` cohort BCF + .csi, audit Parquet) | `pipeline/vcf.py` |
| `simurg export bedgraph | finalreport | plink | locus-summary` | `pipeline/export.py` |
## Acceptance (plan §18 P6)
### bcftools validation and indexes
Every VCF passes `bcftools view`; `bcftools index --nrecords` on our `.tbi` equals the record count (635 796); region
queries through our index (`1:1-2M`, `X:150-156M`, `MT`, `Y`, `22:30.0-30.1M`) return exactly the records a full scan
finds. The cohort BCF reads back identically to the per-sample VCFs (635 796 records × 3 samples, 0 differences in
GT/GS/BAF/LRR) and its CSI index answers region queries.
### Genotypes vs gtc2vcf (MIT), 36 samples, one record per probe (`--unsquash-duplicates`)
Both sides left-aligned with `bcftools norm -f`, compared per probe and sample as allele sets.
| Category | Count (probe × sample) | Explanation |
|---|---|---|
| identical calls | 23 656 010 | |
| identical no-calls | 102 077 | |
| our merged multi-allelic site differs from the single-probe call | 2 414 | e.g. an Infinium II [G/T] probe calls GG while another probe at the site shows G/C: C shares G's dye, so the probe cannot see it — our site genotype is consistent with every probe |
| our merged site missing, probe called | 3 945 | the probes at the site contradict each other (`W_MERGE_INCONSISTENT`) or leave several genotypes (`W_MERGE_AMBIGUOUS`) |
| our merged site called, probe no-call | 65 | other probes at the site resolve it |
| het in a haploid context → missing (OUT-018) | 14 | male X non-PAR and MT heterozygous calls |
| female Y → missing (OUT-018) | 11 | |
| indels gtc2vcf leaves unresolved (`REF=I/D`) that we resolve | 144 probes | checked against GTCtoVCF below |
| **unexplained differences** | **0** | |
Locus sets: gtc2vcf writes 22 400 intensity-only loci that SimurgArray skips by design (OUT-019), and 49 indels it
cannot resolve either (`W_SKIP_INDEL_REF` for us). Nothing we write is missing from gtc2vcf.
### Genotypes vs GTCtoVCF (Apache-2.0), 36 samples, merged sites
(filled in below)
### PLINK vs plink2 (GPL, subprocess oracle, built from source)
`simurg export plink` and `plink2 --bcf <bcftools norm -m- of our BCF> --make-bed --split-par hg38` agree on all
637 397 variants × 3 samples (bed codes identical; the 1 601 extra variants are split multi-allelic sites).
### BAF orientation, bedGraph
* EDGE-017: at every called homozygous single-probe site where the probe's A allele is ALT, VCF BAF = 1 − GTC BAF
(float32), otherwise unchanged (> 500 sites of each kind checked per sample).
* EDGE-023: raw LRR bedGraph values equal the GTC values after `%.6g` formatting for every single-probe position.
### Not yet implemented (listed in known_unknowns K-023)
`--auxiliary-loci` (OUT-035), `--info-fields` and `--extra-format` (OUT-012), `--merge-snv-indel` (OUT-021),
PLINK 2 `.pgen` output.