
Genoray Api
- 64 installs
- 5 repo stars
- Updated August 5, 2026
- d-laub/genoray
Genoray-api is an agent skill that documents the public genoray Python API for VCF, PGEN, and SparseVar range queries so developers can write correct genotype and dosage code.
About
Genoray-api is an agent skill that encodes the public genoray Python API for ergonomic VCF, PGEN, and SparseVar range queries with NumPy outputs. Reach for it whenever you add or change code that imports genoray to read genotypes, dosages, phasing, or sparse variant workflows. It maps mode constants, chunking limits, filtering expressions, and SparseVar conversion paths so bioinformatics pipelines stay type-safe and memory-aware.
- Documents the full public genoray surface: VCF, PGEN, SparseVar, Reference, and signature helpers
- Explains mode class attributes, half-open ranges, and chunk max_mem conventions
- Points agents to authoritative docs and source docstrings instead of guessing shapes
Genoray Api by the numbers
- 64 all-time installs (skills.sh)
- Ranked #889 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/d-laub/genoray --skill genoray-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 5, 2026 |
| Repository | d-laub/genoray ↗ |
How do you use genoray's VCF and PGEN readers without importing private modules or misconfiguring modes, dosage fields, and chunk memory limits?
Guide Python changes that read genotypes and dosages from VCF, PGEN, or SparseVar files via genoray without breaking public API contracts.
Who is it for?
Python engineers building genomic or variant pipelines who need fast, correct range queries over VCF, PGEN, or .svar files with NumPy.
Skip if: Teams doing unrelated bioinformatics work or projects that never touch variant file formats or the genoray library.
When should I use this skill?
You are writing or modifying Python that imports genoray to read, chunk, filter, or convert variant data.
What you get
Produces implementation guidance aligned to genoray's public classes, mode tuples, filtering exprs, and documented SparseVar workflows.
Files
genoray public API
genoray is a NumPy-first range-query layer over VCF/BCF (cyvcf2), PGEN (pgenlib), and a sparse memmap format (SparseVar / .svar).
Public surface
import genoray exposes exactly:
genoray.PGEN— PLINK 2 PGEN readergenoray.Reference— indexed-FASTA reference genome readergenoray.VCF— VCF/BCF readergenoray.Reader— type aliasVCF | PGEN | SparseVargenoray.SparseVar— sparse.svarreader/writergenoray.exprs— polars filter expressions for.gviindexesgenoray.cosmic_signatures— fetch/cache COSMIC reference signaturesgenoray.fit_signatures— sparse forward-selection signature refit
Nothing else is public. Anything starting with _ (e.g. genoray._vcf) is internal — do not import it from user code.
Where to look for details
Prefer reading these over guessing:
docs/source/index.md— narrative tour with full examples (VCF, PGEN, filtering, chunking)docs/source/svar.md— SparseVar usagegenoray/__init__.py— confirms the public surfacegenoray/_vcf.py—VCFclass: constructor,read,chunk, mode constants near the top of the classgenoray/_pgen.py—PGENclass: constructor,read,chunk,read_ranges,chunk_ranges, mode constants near the top of the classgenoray/_svar.py—SparseVar:__init__,from_vcf,from_pgen,read_ranges,with_fields,annotate_mutations,mutation_matrix,assign_signaturesgenoray/_signatures.py—cosmic_signatures,fit_signaturesgenoray/_reference.py—Reference:from_path,fetch,contig_arraygenoray/exprs.py— the complete set of pre-built filter expressions (currently 7:is_snp,is_indel,is_biallelic,is_symbolic,is_breakend,is_imprecise,ILEN)
When a signature, kwarg, or shape is unclear, read the docstring in the source rather than reasoning from first principles.
Cross-cutting conventions
- Ranges are 0-based, half-open
[start, end). max_memaccepts strings like"4g","512m","2GB".- Contig names auto-normalize:
"chr1"and"1"both work regardless of file convention (ContigNormalizer). - Missing genotype =
-1(int). Missing dosage =np.nan(float32). - Ploidy is always 2.
- All return arrays are NumPy;
modeselects which arrays you get back.
Mode constants — gotcha
Modes are class attributes, not top-level names:
genoray.VCF.Genos8 # not genoray.Genos8
genoray.PGEN.GenosPhasingDosagesTo discover the available modes for a class, read the class body in _vcf.py / _pgen.py (search for Genos near the top).
When a mode bundles multiple arrays, the return tuple follows the order in the constant name. PGEN.GenosPhasingDosages returns (genos, phasing, dosages); VCF.Genos8Dosages returns (genos, dosages).
VCF — quick reference
vcf = genoray.VCF(
"file.vcf.gz",
phasing=True, # constructor-time, not per-read
dosage_field="DS", # required to read dosages; FORMAT field with Number=A
filter=lambda v: ..., # cyvcf2.Variant -> bool
pl_filter=~genoray.exprs.is_symbolic, # drop <DEL>/<INS>/...; pair with a matching `filter` callable
)
# Single range
arr = vcf.read("chr1", start=0, end=1_000_000, mode=genoray.VCF.Genos8)
# Chunked
for chunk in vcf.chunk("chr1", start=0, end=1_000_000,
max_mem="2g", mode=genoray.VCF.Genos8Dosages):
...- Shape with
phasing=False:(samples, ploidy=2, variants). - Shape with
phasing=True:(samples, ploidy+1=3, variants)— the 3rd row along the ploidy axis is0(unphased) /1(phased), matching cyvcf2. - Dosage arrays drop the ploidy axis:
(samples, variants), dtypefloat32. - VCF intentionally has no `read_ranges` — benchmarking showed no throughput benefit.
PGEN — quick reference
pgen = genoray.PGEN(
"hardcalls.pgen", # hardcalls live in the main path
dosage_path="dosages.pgen", # optional; defaults to the main path
filter=genoray.exprs.is_snp & genoray.exprs.is_biallelic,
)Important: when you have a dosage-only PGEN and a separate hardcalls PGEN, hardcalls go in the main path and dosages go in dosage_path. If you only pass one path, both hardcalls and dosages come from it (with the hardcalls inferred from dosage threshold — see PLINK 2 docs).
A .gvi index file is created next to the PGEN on first construction. Don't delete it.
# Single range
genos = pgen.read("chr2", start=0, end=1000)
# Multiple ranges in one call (PGEN-only optimization)
data, offsets = pgen.read_ranges(
"chr2",
starts=[0, 1000, 2000],
ends=[1000, 2000, 3000],
mode=genoray.PGEN.GenosPhasingDosages,
)
# `data` matches the mode (tuple when mode bundles multiple arrays)
# `offsets` shape: (n_ranges + 1,). Slice range i with: arr[..., offsets[i]:offsets[i+1]]
# Chunked variants of both
for chunk in pgen.chunk("chr2", 0, 1000, max_mem="4g"): ...
for range_iter in pgen.chunk_ranges("chr2", starts, ends, max_mem="4g"):
for chunk in range_iter: ...Genotype dtype: int32. Dosage dtype: float32. Phasing is a separate bool array of shape (samples, variants) — not an extra row in the genotype array (unlike VCF with phasing=True).
SparseVar (.svar) — quick reference
Build:
# From a configured VCF reader
vcf = genoray.VCF("file.vcf.gz", dosage_field="DS")
genoray.SparseVar.from_vcf("out.svar", vcf, max_mem="4g",
with_dosages=True, overwrite=True)
# Or from a PGEN
genoray.SparseVar.from_pgen("out.svar", "file.pgen", max_mem="4g")SparseVar.from_vcf / from_pgen inherit and apply the source's filter — filter the VCF/PGEN to filter the SVAR.
SparseVar.from_vcf / from_pgen accept regions=, samples=, merge_overlapping=, regions_overlap= to subset by region and/or sample during conversion (same semantics as SparseVar.write_view); a sample subset drops MAC=0 variants from the output.
Read:
# Plain ragged: data is just variant indices
svar = genoray.SparseVar("out.svar")
ragged = svar.read_ranges("chr1", starts=[0, 50_000], ends=[10_000, 60_000],
samples=["S1", "S2"])
# shape: (ranges, samples, ploidy, ~variants) — last axis is ragged
# With extra fields attached
svar = genoray.SparseVar("out.svar", fields={"dosages": np.float32})
# or, on an existing instance:
svar_with = svar.with_fields({"dosages": np.float32})
result = svar_with.read_ranges("chr1", [0], [10_000])
result.genos # Ragged of variant indices (uint32)
result.dosages # Ragged of dosages (float32)with_fields(False) drops all extras and returns a plain Ragged[V_IDX_TYPE] again from subsequent reads.
Each leaf value in the ragged result is a variant index — a row number into svar.index, a polars DataFrame with at least CHROM, POS, REF, ALT (list[str]), ILEN. To map indices back to chrom/pos/ref/alt, row-index that DataFrame.
v_idxs = ragged[0, 0, 0].to_numpy()
rows = svar.index[v_idxs.tolist()].select("CHROM", "POS", "REF", "ALT")svar.index.POS is 1-based (VCF convention), while query coordinates are 0-based half-open. Don't conflate them.
Filtering
VCF: pass a Callable[[cyvcf2.Variant], bool] to filter=. For index-based predicates (e.g. is_symbolic), also pass the matching polars pl.Expr to pl_filter= — VCF requires both when filtering via the .gvi index.
To change a VCF's filter after construction, assign a (filter, pl_filter) tuple to the vcf.filter setter (or None to clear both); the same both-or-neither invariant is enforced, and the in-memory index is invalidated. The getter returns the (filter, pl_filter) tuple, mirroring the setter ((None, None) when unset), so vcf.filter = vcf.filter round-trips.
vcf.filter = (lambda rec: ..., ~genoray.exprs.is_symbolic) # set both
vcf.filter = None # clear both
fn, expr = vcf.filter # get bothPGEN: pass a polars pl.Expr returning a boolean mask, operating on the .gvi index columns. Built-in expressions in genoray.exprs (the complete list):
is_snp(True if all ALT alleles have ILEN == 0; rows with anynullILEN → False)is_indel(True if all ALT alleles have ILEN != 0; rows with anynullILEN → False)is_biallelicis_symbolic(True if any ALT is a VCF 4.x symbolic allele, i.e. starts with<)is_breakend(True if any ALT is a VCF 4.x breakend in mate-pair / single-breakend notation, e.g.G[chr2:321[,]chr2:321]G,.TGCA,TGCA.. A distinct ALT class from symbolic alleles —is_symbolicdoes not flag breakends)is_imprecise(True if any ALT's ILEN isnull— an un-sizable symbolic allele or a breakend)ILEN(aList[Int32]expression — one value per ALT allele, not a boolean)
`ILEN` semantics for symbolic SVs. For precise <DEL>/<INS>/<DUP>, ILEN is computed at index-build time from INFO fields: -|SVLEN| for <DEL>, +|SVLEN| for <INS>/<DUP> (falls back to |END - POS| when SVLEN is absent). For VCF, INFO fields are read from header-declared columns (via oxbow); for PGEN, they are parsed from the PVAR INFO string. Non-symbolic ALTs use the literal len(ALT) - len(REF).
Un-sizable symbolic alleles carry `null` ILEN. An allele is un-sizable when: the IMPRECISE INFO flag is set, SVLEN/END are both missing, the symbolic type is unsupported (<BND>, <CNV>, <INV>, <*>/<NON_REF>), or the ALT is a breakend in mate-pair / single-breakend notation (e.g. G[chr2:321[). At NumPy materialization, null ILEN is coerced to 0 (treated as a point variant).
Filtering guidance (no new constructor kwarg — use the existing filter/pl_filter API):
~genoray.exprs.is_symbolic— drops all symbolic alleles (precise or not).
Required for haplotype consumers (e.g. genvarloader) that cannot expand any symbolic ALT into literal sequence:
# PGEN
pgen = genoray.PGEN("file.pgen", filter=~genoray.exprs.is_symbolic)
# VCF (both required)
vcf = genoray.VCF(
"file.vcf.gz",
filter=lambda rec: not any(a.startswith("<") for a in rec.ALT),
pl_filter=~genoray.exprs.is_symbolic,
)~genoray.exprs.is_imprecise— keeps precise symbolic SVs (correctly
sized/spanned) and drops only the un-sizable ones (including breakends, which are always un-sizable). Suitable for range/overlap queries where precise SVs are queryable:
pgen = genoray.PGEN("file.pgen", filter=~genoray.exprs.is_imprecise)- For haplotype consumers, drop all un-expandable ALTs (symbolic and
breakends) — breakends are not caught by ~is_symbolic:
hap_safe = ~genoray.exprs.is_symbolic & ~genoray.exprs.is_breakend
pgen = genoray.PGEN("file.pgen", filter=hap_safe)For anything else, write pl.col(...) against the .gvi schema — read genoray/exprs.py for the available columns. Combining two exprs expressions with & / | works without importing polars; you only need import polars as pl to build custom predicates.
Reference — quick reference
genoray.Reference is a pysam-backed indexed-FASTA reader used to supply flanking context for mutation-catalogue classification.
ref = genoray.Reference.from_path("hg38.fa") # auto-creates .fai if absent
ref = genoray.Reference.from_path("hg38.fa", contigs=["chr1", "chr2"])
seq: np.ndarray = ref.fetch("chr1", start=1_000_000, end=1_000_010)
# returns uint8 NDArray, 0-based half-open [start, end)
# bytes(seq) gives the ASCII sequenceKey properties:
from_path(fasta, contigs=None)—fastais astr | Path; auto-callspysam.faidxif the.faiindex is missing.contigsfilters which contigs the caller cares about (defaults to all in the FASTA).fetch(contig, start, end)— 0-based half-open[start, end). Positions outside the contig are N-padded. ReturnsNDArray[np.uint8].contig_array(contig)— the full contig sequence as a cachedNDArray[np.uint8]. Shares the one-contig-in-memory cache withfetch. Acceptschr-prefixed or unprefixed names.- Contig-name agnostic:
"chr1"and"1"both resolve correctly (ContigNormalizerunder the hood). - One contig is cached in memory at a time; sequential per-contig access is efficient.
Mutation catalogues (SBS-96 / DBS-78 / ID-83)
Overview
SparseVar supports COSMIC-style mutation catalogues. The workflow is:
1. Call svar.annotate_mutations(reference) once to classify every variant and write mutcat.npy to the .svar directory. 2. Call svar.mutation_matrix(kind) to get a per-sample count matrix.
SparseVar.annotate_mutations
svar = genoray.SparseVar("out.svar")
ref = genoray.Reference.from_path("hg38.fa")
svar.annotate_mutations(ref) # write_back=True (default)
svar.annotate_mutations(ref, write_back=False) # in-memory only; not persisted
svar.annotate_mutations("hg38.fa") # path accepted directlySignature: annotate_mutations(reference, *, contigs=None, write_back=True) -> None
reference— agenoray.Referenceinstance or a path to a FASTA file
(auto-wraps via Reference.from_path).
contigs=None— if given (a list of contig names), only variants on those
contigs are classified; entries on all other contigs are marked NOT_ANNOTATED (sentinel -4) and their contigs are never fetched from the reference. Names match via the ContigNormalizer (chr1/1 both work). Requested contigs absent from the .svar index are skipped with a warning; a listed contig present in the index but absent from the reference raises (omit it from the list to exclude it cleanly). None (default) classifies all contigs. When write_back=True, the normalized scope is recorded in metadata.json as mutcat_contigs (None = all).
write_back=True— persistsmutcat.npyand updatesmetadata.jsonso
that subsequent SparseVar(dir, fields=["mutcat"]) opens will see the field. Note: write_view never copies mutcat positionally to the output (see below); pass reference= to write_view to recompute it on the subset, or call annotate_mutations on the output view yourself.
write_back=False— themutcatfield lives only in memory
(svar.fields["mutcat"]); reopening the file will NOT find it.
- After the call,
svar.fields["mutcat"]is populated regardless of
write_back.
What it classifies:
| Variant type | Channel |
|---|---|
| Isolated SNV | SBS-96 (trinucleotide context) |
| Adjacent SNV pair on the same haplotype | DBS-78 (5' entry = DBS code, 3' entry = DBS_PARTNER sentinel) |
| Runs of ≥ 3 adjacent SNVs | SBS (each stays independent; no DBS collapse) |
| Native 2 bp MNV in the VCF | DBS-78 |
| MNV > 2 bp, symbolic, non-ACGT | UNCLASSIFIED |
| Insertion / deletion | ID-83 (size, repeat-context bucketing) |
Variant on a contig outside contigs= | NOT_ANNOTATED (excluded from all matrices) |
SparseVar.mutation_matrix
svar = genoray.SparseVar("out.svar", fields=["mutcat"]) # pre-load field
df = svar.mutation_matrix("SBS96") # default count="allele"
df = svar.mutation_matrix("DBS78", count="sample")
df = svar.mutation_matrix("ID83", count="allele")Signature: mutation_matrix(kind, *, count="allele") -> pl.DataFrame
kind— one of"SBS96","DBS78","ID83".count="allele"— counts every non-ref allele copy (diploid homozygous = 2).count="sample"— counts each category at most once per sample (presence/absence).- Returns a Polars
DataFramewith aMutationTypestring column followed by
one Int64 column per sample. Rows are in fixed COSMIC codebook order (96 / 78 / 83 rows respectively).
- Requires the
mutcatfield to be available: either loaded at open time with
fields=["mutcat"], or already in memory from a prior annotate_mutations call, or present on disk from a prior annotate_mutations(write_back=True). Raises ValueError if none of those hold.
The mutcat field
mutcat is an int16 field stored per genotype entry (same ragged layout as genos). The int16 code space is:
| Range | Channel |
|---|---|
[0, 96) | SBS-96 |
[96, 174) | DBS-78 |
[174, 257) | ID-83 |
-1 | DBS_PARTNER — 3' half of an adjacent SNV pair; never counted |
-2 | UNCLASSIFIED — symbolic / complex / MNV > 2 bp / non-ACGT |
-3 | MISSING — reserved sentinel (defined in the code space but not emitted by annotate_mutations v1; SparseVar stores only ALT-carrying entries, so no-call slots do not appear in the ragged field) |
-4 | NOT_ANNOTATED — entry on a contig outside the contigs= annotation scope; never counted |
To read a previously annotated file:
svar = genoray.SparseVar("out.svar", fields=["mutcat"])
# svar.fields["mutcat"] is a Ragged[int16] mirroring svar.genosv1 scope limits (no strand-bias; calibrated against PCAWG/SigProfiler rules)
- No strand-bias separation (no SBS-192 / transcriptional strand).
- DBS collapse applies only to isolated adjacent pairs on the same
haplotype. Runs of ≥ 3 adjacent SNVs stay as individual SBS entries.
- Indel channel (ID-83) bucketing follows PCAWG/SigProfiler published rules and
is pinned by the unit tests in tests/test_mutcat.py. Cross-validation against SigProfilerMatrixGenerator is deferred (it is not a declared dependency).
Signature refitting (COSMIC)
Decompose a catalogue into per-sample COSMIC signature activities.
import genoray
ref = genoray.cosmic_signatures("SBS96") # pooch-fetched + cached
cat = svar.mutation_matrix("SBS96") # MutationType + sample cols
act = genoray.fit_signatures(cat, ref) # activities + cosine_similarity
# convenience: mutation_matrix -> fit_signatures in one call
act = svar.assign_signatures("SBS96") # default COSMIC ref
act = svar.assign_signatures("SBS96", reference=ref, min_activity=0.01)
act = svar.assign_signatures("SBS96", reference="my_sigs.txt") # TSV pathSignatures:
cosmic_signatures(kind, *, version="3.4", genome="GRCh38") -> pl.DataFrame
— fetches/caches the COSMIC reference set for kind ∈ {"SBS96","DBS78","ID83"}. Returns a MutationType column (canonical codebook order) + one column per signature. genome is ignored for ID83.
fit_signatures(catalogue, reference, *, max_delta=0.01, min_activity=0.005) -> pl.DataFrame
— sparse forward-selection refit (NNLS + cosine-guided add + min-activity prune). Aligns rows by joining on MutationType (raises ValueError if the catalogue has a type missing from the reference). Returns one row per sample: Sample, one Float column per signature (counts; 0.0 if unselected), and cosine_similarity.
SparseVar.assign_signatures(kind, *, reference=None, count="allele", max_delta=0.01, min_activity=0.005) -> pl.DataFrame
— mutation_matrix(kind, count=...) then fit_signatures(...). reference accepts a pl.DataFrame, a TSV path, or None (defaults to cosmic_signatures(kind)).
Out of scope (v1): de novo extraction, opportunity normalization, bootstrap CIs, plotting.
Common mistakes
| Mistake | Fix |
|---|---|
genoray.Genos8 | genoray.VCF.Genos8 (class attribute) |
vcf.read(..., phasing=True) | Set phasing=True on the VCF() constructor |
Reading dosages from a VCF without dosage_field= | Pass dosage_field="DS" (or appropriate Number=A field) on the constructor |
| Putting a dosage-only PGEN in the main path when you also have hardcalls | Hardcalls in main path, dosages in dosage_path= |
Importing from genoray._vcf import VCF | Use from genoray import VCF |
Expecting VCF to have read_ranges | VCF doesn't; loop over single-range read calls, or use PGEN/SparseVar |
Treating svar.index["POS"] as 0-based | It's 1-based; subtract 1 to compare with query coords |
Calling read_ranges and assuming a flat array | PGEN returns (data, offsets); SparseVar returns a Ragged (or awkward record with fields) |
Calling mutation_matrix without a mutcat field | Run annotate_mutations first, or open with fields=["mutcat"] |
Expecting mutation_matrix to auto-run annotation | It does not; call annotate_mutations separately |
Re-opening SparseVar and losing the mutcat field | Use write_back=True (default) in annotate_mutations; then open with SparseVar(dir, fields=["mutcat"]) |
Calling write_view and expecting mutcat to be in the output | write_view never copies mutcat positionally (subsetting invalidates DBS adjacency codes). Pass reference= to write_view to recompute it on the subset, or call annotate_mutations on the output view yourself. Explicitly including "mutcat" in fields= without a reference= raises ValueError. |
Passing a FASTA path directly to annotate_mutations | Supported — it auto-wraps via Reference.from_path |
When this skill needs updating
Any PR that adds, removes, renames, or changes the semantics of a public name (anything reachable from import genoray without underscores) must update this skill alongside the code change. See the project CLAUDE.md.
Related skills
FAQ
Which classes are part of the public genoray API?
PGEN, VCF, SparseVar, Reference, cosmic_signatures, fit_signatures, and genoray.exprs filter helpers.
How should agents pick genotype return modes?
Use class attributes such as VCF.Genos8 or PGEN.GenosPhasingDosages, not top-level names, and match tuple order in the constant name.
When is a dosage_field required on VCF?
You must pass dosage_field (for example DS) with Number=A in the header before read modes that return dosages.