Skip to content

feat(xenium): import onboard secondary analysis (clustering/PCA/UMAP/diffexp) into the table - #405

Open
Tomatokeftes wants to merge 4 commits into
scverse:mainfrom
Tomatokeftes:feat/xenium-cells-analysis
Open

feat(xenium): import onboard secondary analysis (clustering/PCA/UMAP/diffexp) into the table#405
Tomatokeftes wants to merge 4 commits into
scverse:mainfrom
Tomatokeftes:feat/xenium-cells-analysis

Conversation

@Tomatokeftes

Copy link
Copy Markdown

Motivation

xenium() currently imports only the raw outputs (boundaries, transcripts, images, cell-feature matrix). The Xenium Onboard Analysis secondary analysis under analysis/ — graph-based + k-means clustering, PCA, UMAP, and differential expression — is dropped, so the 10x-computed clusters/embeddings have to be recomputed downstream even though they ship with every standard run. I couldn't find an existing issue/PR covering this (the nearby #385 is about Xenium Explorer selection GeoJSON, a different artifact).

What this does

Adds a cells_analysis: bool = True option to xenium() that, when the analysis/ folder is present, enriches the cell table:

Source Target
analysis/clustering/<name>/clusters.csv one categorical column per clustering in table.obs (named <name>, e.g. gene_expression_graphclust)
analysis/pca/<name>/projection.csv table.obsm["X_pca"]
analysis/umap/<name>/projection.csv table.obsm["X_umap"]
analysis/diffexp/<name>/differential_expression.csv table.uns["diffexp"][<name>]

Design notes:

  • Everything is joined to the cells by cell_id (the CSV Barcode column == table.obs_names), so it stays aligned with the shapes/table index regardless of row order.
  • Cells absent from a given result (e.g. filtered out by QC before clustering) get a missing value (NaN category / NaN obsm row), never dropped — so n_obs is unchanged.
  • Cluster ids are stored as string categories ("1", "2", …), idiomatic for scanpy/squidpy plotting.
  • A missing analysis/ folder (re-segmented data, matrix-only exports) is a no-op.
  • Opt-out via cells_analysis=False; requires cells_table=True.

Tests

Self-contained unit tests for the parser (_add_cells_analysis): join-by-barcode with scrambled/partial rows, missing-cell handling, obsm alignment with NaN rows, and the no-op-when-absent case. No network/example data required. Verified end-to-end on a real Xenium 2.x run (24,005 cells): all 10 clusterings land in obs, X_pca/X_umap in obsm, with the QC-filtered cells correctly left as NaN.

Notes

  • New constants added to XeniumKeys; ruff lint + format clean.
  • Happy to adjust the default (True vs False), the obs column naming, or whether diffexp belongs in uns — flagging those as the main review-judgment calls.

Add a `cells_analysis` option (default True) to `xenium()` that reads the
Xenium output's `analysis/` folder into the cell table when present:

- `analysis/clustering/<name>/clusters.csv` -> one categorical column per
  clustering in `table.obs` (e.g. `gene_expression_graphclust`,
  `gene_expression_kmeans_10_clusters`), joined to the cells by `cell_id`
  (the CSV `Barcode`). Cells absent from a clustering (filtered by QC) get a
  missing value rather than being dropped.
- `analysis/pca/<name>/projection.csv`  -> `table.obsm["X_pca"]`.
- `analysis/umap/<name>/projection.csv` -> `table.obsm["X_umap"]`.
- `analysis/diffexp/<name>/differential_expression.csv` ->
  `table.uns["diffexp"][<name>]`.

Until now `xenium()` imported only the raw outputs (boundaries, transcripts,
images, cell-feature matrix); the onboard secondary analysis was dropped, so
the 10x-computed clusters/embeddings had to be recomputed downstream. Joining
by `cell_id` keeps everything aligned to the shapes/table index. A missing
`analysis/` folder is a no-op (e.g. re-segmented data, matrix-only exports).

Adds self-contained unit tests for the parser (join-by-barcode, missing-cell
handling, obsm alignment, no-op when the folder is absent).
…ndex

The analysis CSVs key on the cell_id barcode; join clustering + projections on
the cell_id obs column instead of obs_names so the import stays correct even
when the table index is positional rather than the barcode.
The CLI-completeness test (test_cli_exposes_all_reader_params) requires every
xenium() parameter to have a matching click option in xenium_wrapper. Add the
--cells-analysis option + param + pass-through for the new cells_analysis kwarg.
@codecov-commenter

codecov-commenter commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.54839% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.98%. Comparing base (a63ca08) to head (fbeaf04).

Files with missing lines Patch % Lines
src/spatialdata_io/readers/xenium.py 92.15% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #405      +/-   ##
==========================================
+ Coverage   63.41%   63.98%   +0.56%     
==========================================
  Files          26       26              
  Lines        3217     3279      +62     
==========================================
+ Hits         2040     2098      +58     
- Misses       1177     1181       +4     
Files with missing lines Coverage Δ
src/spatialdata_io/__main__.py 84.61% <100.00%> (+0.06%) ⬆️
src/spatialdata_io/_constants/_constants.py 100.00% <100.00%> (ø)
src/spatialdata_io/readers/xenium.py 76.76% <92.15%> (+1.95%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Tomatokeftes

Tomatokeftes commented Jun 18, 2026

Copy link
Copy Markdown
Author

CI triage note (updated 2026-07-30)

The Python 3.12/3.13 failures on the 2026-06-18 run were not caused by this PR, and they are no longer reproducible.

Root cause was a spatialdata / anndata incompatibility in the shared blobs() fixture, entirely inside site-packages:

spatialdata/_core/validation.py:153: AttributeError: 'NoneType' object has no attribute 'lower'
  keys = KeysView(Layers with keys: None), location = ('layers',)

reached via spatialdata.datasets.blobs() -> TableModel.parse -> validate_table_attr_keys, together with 63 occurrences of ValueError: Key 'Abc' is not unique from the same module. Because blobs() is shared, it hit test_generic, test_visium_hd, test_seqfish, test_legacy_anndata and, collaterally, the Xenium example-data tests — readers this PR does not touch.

It has since been resolved upstream, without main moving:

  • the v0.7.1 release run on 2026-07-02 built the identical base commit (a63ca08, this PR's merge-base) and passed 3.11, 3.12 and 3.13;
  • later branches are green on the full matrix (feat/mcmicro-nfcore-support on 2026-07-22, feat/adopt-scverse-template on 2026-07-29).

I have pushed an empty commit to re-trigger CI against the current releases: the full 3.11 / 3.12 / 3.13 matrix now passes. No rebase is needed: main is still at a63ca08, and the branch is 3 ahead / 0 behind and mergeable.

Codecov is now green too: patch coverage 93.5%, project coverage +0.56%. The earlier -25.36% was an artifact of the two failed jobs uploading no report, not a real regression.

This is ready for review.

The 2026-06-18 failures on Python 3.12/3.13 came from a spatialdata /
anndata incompatibility in the shared blobs() fixture, not from this
branch. That has since been resolved upstream (the v0.7.1 release run
built this same base commit green on 3.11/3.12/3.13 on 2026-07-02), so
this empty commit just re-triggers CI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants