Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/host-your-own-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,9 @@ https://bucket.example/path/to/sample.h5ad
The H5AD reader requires:

- `/obs` with a readable dataframe index for cell IDs.
- `/var` with a readable dataframe index. A plain (non-categorical)
`gene_symbols`, `gene_symbol`, `feature_name`, or `name` column replaces the
index as the gene name.
- `/var` with a readable dataframe index. A `gene_symbols`, `gene_symbol`,
`feature_name`, or `name` column, plain or categorical, replaces the index as
the gene name, and the index stays searchable as an alias.
- `/obsm` with an `n_obs x 2+` coordinate matrix. The first of `X_spatial`,
`spatial`, `X_umap`, `umap`, `X_tsne`, `X_tSNE`, `tsne`, `X_pca` is used for
positions, and every value must be finite.
Expand Down
17 changes: 12 additions & 5 deletions docs/modalities/h5ad.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ dataset instance (`cachePromise`, reset on error).
datasets named `_index`, `index`, `obs_names`/`var_names`, then `cell_id`
(obs) or `gene_ids` (var). Names are trimmed; empty names become `obs-N` or
`var-N`; duplicates get `-2`, `-3`, … suffixes.
- **Gene names** (`readGeneNames`): the first plain (non-categorical) dataset
among `gene_symbols`, `gene_symbol`, `feature_name`, `name` that has one
non-empty value per gene replaces the var index. A categorical
`feature_name` (common in CELLxGENE files) is skipped, so those files show
the index (often Ensembl IDs).
- **Gene names** (`readGeneNames`): the first column among `gene_symbols`,
`gene_symbol`, `feature_name`, `name` with one value per gene and at least
one non-empty value replaces the var index. A column can be a string
dataset, a categorical group (`codes` + `categories`, as CELLxGENE stores
`feature_name`), or integer codes with a legacy `/var/__categories/<key>`
sidecar. Negative codes become empty names. The index's rules then apply:
empty names become `var-N` and duplicates get `-2`, `-3`, … suffixes.
- **Gene aliases** (`geneAliases`): when a column replaces the index, each
gene's index name (often its Ensembl ID) is kept as an alias. The
`Gene selection` search and `Gene set symbols` match aliases too, and the
gene list tooltip shows it (`MYH7 (ENSG00000092054)`). Selected genes,
shared links, and manifest `defaults.genes` use the listed names only.

### `obs` Columns

Expand Down
4 changes: 2 additions & 2 deletions docs/ui-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ mode, …). Hidden for Image.

| Control | Type | Values / default | Notes |
| ----------------------- | ------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `Gene selection` | Search | | Case-insensitive substring; excludes selected genes |
| `Gene selection` | Search | | Case-insensitive substring of the name or [alias](./modalities/h5ad.md#index-and-gene-names); excludes selected genes |
| Gene list | Virtualized buttons (`GeneOptionList.tsx`) | | Click toggles a gene |
| Selected-gene chips | Buttons | | Click removes |
| `Gene set symbols` | Textarea | e.g. `Actb, Gad1, Slc17a7` | Split on whitespace, comma, semicolon |
| `Gene set symbols` | Textarea | e.g. `Actb, Gad1, Slc17a7` | Split on whitespace, comma, semicolon; matches names, then aliases |
| `Apply Symbols` | Button | | Replaces the selection with matching genes |
| `Save Set` / `Set name` | Button + text | | Saves the selection and score mode for this session |
| Saved sets | Apply / `x` buttons | | Apply restores genes and score mode |
Expand Down
199 changes: 194 additions & 5 deletions src/datasets/h5adDataset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,125 @@ describe('AnnDataH5adDataset', () => {
])
})

it.each<CategoricalEncoding>(['group', 'dataframe-categories'])(
'lists genes by a categorical feature_name (%s) and keeps the index as an alias',
async (categoricalEncoding) => {
const bytes = await createTinyH5ad('dense', {
categoricalEncoding,
featureNames: TINY_FEATURE_NAMES,
})
const dataset = new AnnDataH5adDataset({
url: 'https://example.test/cellxgene.h5ad',
fetcher: h5adFetcher(bytes),
})

await expect(dataset.genePanel()).resolves.toEqual(['Actb', 'Gad1', 'Gapdh'])
await expect(dataset.geneAliases()).resolves.toEqual(
new Map([
['Actb', 'ENSMUSG00000029580'],
['Gad1', 'ENSMUSG00000070880'],
['Gapdh', 'ENSMUSG00000057666'],
]),
)

const expression = await dataset.geneExpression(
{
minX: micrometers(0),
maxX: micrometers(20),
minY: micrometers(0),
maxY: micrometers(20),
},
['Gapdh'],
)

expect(expression.elements.map((element) => [element.id, element.count])).toEqual([
['cell-a', 5],
['cell-b', 0],
['cell-c', 6],
['cell-d', 8],
])
},
)

it('applies the gene-name rules to categorical codes', async () => {
// A missing code is an empty name, and a shared category is a duplicate.
const partial = new AnnDataH5adDataset({
url: 'https://example.test/partial.h5ad',
fetcher: h5adFetcher(
await createTinyH5ad('dense', {
featureNames: { categories: ['Actb'], codes: [0, -1, 0] },
}),
),
})

await expect(partial.genePanel()).resolves.toEqual(['Actb', 'var-1', 'Actb-2'])
await expect(partial.geneAliases()).resolves.toEqual(
new Map([
['Actb', 'ENSMUSG00000029580'],
['var-1', 'ENSMUSG00000070880'],
['Actb-2', 'ENSMUSG00000057666'],
]),
)

// With every code missing the column is skipped and the index is kept.
const empty = new AnnDataH5adDataset({
url: 'https://example.test/empty.h5ad',
fetcher: h5adFetcher(
await createTinyH5ad('dense', {
featureNames: { categories: ['Actb'], codes: [-1, -1, -1] },
}),
),
})

await expect(empty.genePanel()).resolves.toEqual(TINY_GENE_IDS)
await expect(empty.geneAliases()).resolves.toEqual(new Map())
})

it('keeps the var index as gene names without an alias', async () => {
const dataset = new AnnDataH5adDataset({
url: 'https://example.test/index.h5ad',
fetcher: h5adFetcher(await createTinyH5ad('dense')),
})

await expect(dataset.genePanel()).resolves.toEqual(['Actb', 'Gad1', 'Gapdh'])
await expect(dataset.geneAliases()).resolves.toEqual(new Map())
})

it('reads a categorical feature_name through a kerchunk reference', async () => {
const chunks = new RemoteH5adReferenceChunks()
const referenceSpec = createTinyH5adReferenceSpec(
(value) => chunks.ref(value),
'csr',
TINY_FEATURE_NAMES,
)
const dataset = new AnnDataH5adDataset({
url: 'https://example.test/reference-cellxgene.h5ad',
fetcher: h5adRemoteReferenceFetcher(
350 * 1024 * 1024,
referenceSpec,
chunks.bytes(),
() => {
throw new Error('body should not be read')
},
),
})

await expect(dataset.genePanel()).resolves.toEqual(['Actb', 'Gad1', 'Gapdh'])
await expect(dataset.geneAliases()).resolves.toEqual(
new Map([
['Actb', 'ENSMUSG00000029580'],
['Gad1', 'ENSMUSG00000070880'],
['Gapdh', 'ENSMUSG00000057666'],
]),
)

const matrix = await dataset.matrix(['cell-c'])
expect(matrix.cells.get('cell-c')).toEqual({
geneNames: ['Gad1', 'Gapdh'],
counts: [4, 6],
})
})

it('loads oversized H5AD files through a companion kerchunk reference', async () => {
const readBody = vi.fn(() => {
throw new Error('body should not be read')
Expand Down Expand Up @@ -448,6 +567,21 @@ type CategoricalEncoding = 'group' | 'dataframe-categories'
interface TinyH5adOptions {
readonly categoricalEncoding?: CategoricalEncoding
readonly coordinateKey?: string
// Writes the var index as Ensembl IDs and gene symbols as a categorical
// `feature_name`, the CELLxGENE layout.
readonly featureNames?: CategoricalFeatureNames
}

interface CategoricalFeatureNames {
readonly categories: readonly string[]
readonly codes: readonly number[]
}

const TINY_GENE_IDS = ['ENSMUSG00000029580', 'ENSMUSG00000070880', 'ENSMUSG00000057666']
// Unsorted, so the fixture's codes are not the identity.
const TINY_FEATURE_NAMES: CategoricalFeatureNames = {
categories: ['Gapdh', 'Actb', 'Gad1'],
codes: [1, 2, 0],
}

async function createTinyH5ad(
Expand Down Expand Up @@ -487,8 +621,29 @@ async function createTinyH5ad(
}

const varGroup = file.create_group('var')
varGroup.create_attribute('_index', 'gene_ids')
varGroup.create_dataset({ name: 'gene_ids', data: ['Actb', 'Gad1', 'Gapdh'] })

if (options.featureNames === undefined) {
varGroup.create_attribute('_index', 'gene_ids')
varGroup.create_dataset({ name: 'gene_ids', data: ['Actb', 'Gad1', 'Gapdh'] })
} else {
varGroup.create_attribute('_index', '_index')
varGroup.create_attribute('column-order', ['feature_name'])
varGroup.create_dataset({ name: '_index', data: [...TINY_GENE_IDS] })
const codes = new Int16Array(options.featureNames.codes)
const categories = [...options.featureNames.categories]

if (categoricalEncoding === 'group') {
const featureName = varGroup.create_group('feature_name')
featureName.create_attribute('encoding-type', 'categorical')
featureName.create_attribute('encoding-version', '0.2.0')
featureName.create_dataset({ name: 'categories', data: categories })
featureName.create_dataset({ name: 'codes', data: codes })
} else {
varGroup.create_dataset({ name: 'feature_name', data: codes })
const varCategories = varGroup.create_group('__categories')
varCategories.create_dataset({ name: 'feature_name', data: categories })
}
}

const obsm = file.create_group('obsm')
obsm.create_dataset({
Expand Down Expand Up @@ -629,6 +784,7 @@ type ChunkRefFactory = (value: ArrayBufferView) => string | [string, number, num
function createTinyH5adReferenceSpec(
chunkRef: ChunkRefFactory = bytesRef,
matrixKind: MatrixKind = 'csr',
featureNames?: CategoricalFeatureNames,
): Record<string, unknown> {
return {
version: 1,
Expand All @@ -651,9 +807,13 @@ function createTinyH5adReferenceSpec(
'obs/__categories/cluster/.zarray': zarrArrayRef([2], [2], '|S1'),
'obs/__categories/cluster/0': chunkRef(fixedStringBytes(['A', 'B'], 1)),
'var/.zgroup': jsonRef({ zarr_format: 2 }),
'var/.zattrs': jsonRef({ _index: 'gene_ids' }),
'var/gene_ids/.zarray': zarrArrayRef([3], [3], '|S5'),
'var/gene_ids/0': chunkRef(fixedStringBytes(['Actb', 'Gad1', 'Gapdh'], 5)),
...(featureNames === undefined
? {
'var/.zattrs': jsonRef({ _index: 'gene_ids' }),
'var/gene_ids/.zarray': zarrArrayRef([3], [3], '|S5'),
'var/gene_ids/0': chunkRef(fixedStringBytes(['Actb', 'Gad1', 'Gapdh'], 5)),
}
: tinyCategoricalVarRefs(chunkRef, featureNames)),
'obsm/.zgroup': jsonRef({ zarr_format: 2 }),
'obsm/X_umap/.zarray': zarrArrayRef([4, 2], [4, 2], '<f4'),
'obsm/X_umap/0.0': chunkRef(new Float32Array([0, 0, 10, 0, 0, 10, 10, 10])),
Expand All @@ -664,6 +824,35 @@ function createTinyH5adReferenceSpec(
}
}

function tinyCategoricalVarRefs(
chunkRef: ChunkRefFactory,
featureNames: CategoricalFeatureNames,
): Record<string, unknown> {
const categoryCount = featureNames.categories.length

return {
'var/.zattrs': jsonRef({ _index: '_index', 'column-order': ['feature_name'] }),
'var/_index/.zarray': zarrArrayRef([3], [3], '|S18'),
'var/_index/0': chunkRef(fixedStringBytes(TINY_GENE_IDS, 18)),
'var/feature_name/.zgroup': jsonRef({ zarr_format: 2 }),
'var/feature_name/.zattrs': jsonRef({
'encoding-type': 'categorical',
'encoding-version': '0.2.0',
ordered: false,
}),
'var/feature_name/codes/.zarray': zarrArrayRef([3], [3], '<i2'),
'var/feature_name/codes/0': chunkRef(new Int16Array(featureNames.codes)),
'var/feature_name/categories/.zarray': zarrArrayRef(
[categoryCount],
[categoryCount],
'|S5',
),
'var/feature_name/categories/0': chunkRef(
fixedStringBytes(featureNames.categories, 5),
),
}
}

// Two row chunks of the 4 x 3 matrix.
function tinyDenseMatrixRefs(chunkRef: ChunkRefFactory): Record<string, unknown> {
return {
Expand Down
Loading
Loading