Jupyter Integration (Notebook Embedding)#
This tutorial shows how to load data into Cellucid from inside a notebook (JupyterLab, Jupyter Notebook 7, VS Code, Google Colab, marimo, Databricks).
You will learn:
how to display an AnnData or MuData object, an
.h5ad, a Zarr store, or any other file or folder Cellucid reads withshow_anndata()how to work with vector fields (velocity/drift overlays) in notebooks
how the integration works under the hood (so you can debug it)
how to drive the viewer from Python (highlight/color/visibility), react to UI events (hooks), and pull what you made in the viewer back onto your AnnData
If you are not in a notebook environment, start with Server Mode (CLI + Python). From R, see View an Object in a Jupyter Notebook (R kernel).
Tip
Prefer to begin from a real, runnable notebook? The Pancreas Jupyter walkthrough links the checked notebook and its exact launch command.
At A Glance#
Audience
Wet lab / beginner: copy/paste the “Minimal cells” sections and focus on “What success looks like”.
Computational users: focus on how the viewer reads a store in place, dataset sizes, vector fields, and pulling results back.
Power users: focus on what happens between cells, hooks, and debugging.
Time
Minimal working embed: ~5 minutes
Full read (hooks + troubleshooting): ~20–30 minutes
Prerequisites
A notebook that shows widgets: JupyterLab (tested by the project), Jupyter Notebook 7, VS Code, Google Colab, marimo or Databricks (expected to work, not tested; see Supported environments matrix)
pip install cellucid(it installsanywidget; JupyterLab and Notebook 7 need it where the notebook server runs, the same environment in most setups)
Important
Network requirement: the viewer is the Cellucid web app, which your
browser loads from https://www.cellucid.com into the cell’s output (or
from a copy you host, named with web_source_url=). The kernel needs no
network access and opens no port: the app reads your data from the kernel
through the notebook’s own connection, so nothing is uploaded anywhere. The
widget’s page is a viewer page, which sends no usage analytics.
Minimal Cells (Copy/Paste)#
If you just want it to work, copy/paste one of these flows and then come back for details.
Minimal: show an in-memory AnnData#
from cellucid import show_anndata
viewer = show_anndata(
adata,
height=600,
dataset_name="My study",
dataset_id="my-study-v1",
)
In marimo, end the cell with viewer (marimo shows the widget a cell
evaluates to); every other notebook shows it at once.
Minimal: show a .h5ad file or a Zarr store, read in place#
from cellucid import show_anndata
viewer = show_anndata(
"data.h5ad",
height=600,
dataset_name="My study",
dataset_id="my-study-v1",
)
# A Zarr store folder or a zipped Zarr store (.zip) takes the same arguments.
Minimal: show a Cellucid-ready AnnData#
A file written by cellucid.prepare(...) names itself in uns['cellucid'],
so it needs no dataset_name or dataset_id:
from cellucid import show_anndata
viewer = show_anndata("pbmc_demo.zarr", height=600)
Google Colab#
!pip -q install cellucid
then the same show_anndata(...) cell. Colab renders anywidgets; no port,
proxy or google.colab call is involved.
What success looks like#
The viewer after Python called
viewer.color_by(field={"source": "obs", "key": "clusters"}) and highlighted
the Ductal cells. This capture was taken in a browser tab; in a
notebook the same app sits inside the output cell, and scrolls with the
notebook.#
Three things confirm the call worked:
an interactive viewer appears in the output cell (not an empty area);
in a later cell,
viewer.state.readyis no longerNone;the cell and dimension counts it reports match the data you passed.
How It Works (Mental Model)#
When you call show_anndata(...), Cellucid:
Checks the dataset and your launch choices. It reads only metadata (the cell and gene counts and the names of
obs,var,obsm,obspand the layers) and checks every option you named against it. An in-memoryAnnDataorMuDatais served as a store with its cells in priority order, made from it as the viewer reads it (no copy of it is written as a file).Shows a widget (
cellucid.Widget, an anywidget) in the cell’s output. The widget frames the app athttps://www.cellucid.com/?viewer=embeddedand hands it, in a message, the viewer’s id and token and a private channel.Answers the viewer’s reads. The viewer’s own HDF5/Zarr reader reads the dataset in place with range requests, only what a view needs; the widget relays each request to the kernel, which answers with the bytes on disk, over the notebook’s own connection (a Jupyter comm).
No server is started and no port is opened, so the same cell works on a laptop, on a remote JupyterHub, in VS Code Remote and in Colab.
Between cells#
The kernel handles the widget’s messages between cells, as for every notebook widget:
the viewer loads once the cell that showed it finishes;
hooks (
@viewer.on_selection, …) run, andviewer.stateupdates, whenever the kernel is idle;a call that waits for the viewer’s answer (
wait_for_ready(),wait_for_event(), an action’s or a session request’sresult()) cannot be answered inside the cell that waits, so it raises at once and says what to do instead;actions (
highlight_cells,color_by, …) are sent at once, or delivered in order as soon as the viewer connects.
A browser tab instead#
cellucid.AnnDataViewer(...) is the same API for a viewer in a browser tab of
its own: it starts a local server (port 8765, or the next free one) that
serves the app and the data, and gives you viewer.viewer_url. Use it from a
plain script or through an SSH tunnel (see Remote / HPC Notebooks).
show_anndata() (AnnData / .h5ad / Zarr store)#
show_anndata() is the fastest way to get started in an analysis notebook.
It supports:
an in-memory
AnnDataorMuData, served as a store with its cells in priority order (its arrays copied into memory-mapped files removed when the viewer stops: the object as it was shown, its expression matrices laid out gene-major in the background while the viewer opens, read-only until then; each chunk made from that copy when the viewer asks for it)a
.h5adfile or a Zarr store folder (Zarr v2 with.zgroup, or v3 withzarr.json) or zipped Zarr store (.zip, such as.zarr.zip): oneprepare()wrote read as it is on disk, any other served in priority order, read from the filea
.h5muor MuData Zarr store, a.loomfile or a 10xouts/folder: oneprepare()wrote (a MuData) read as it is, any other served in priority order as the browser presents the file itself (a MuData Zarr store for a MuData, or for a 10x matrix of several feature types), read from the filea MatrixMarket folder or an
.rds/.rda, read as it is on disk, once, by the browser (File format expectations)
Nothing is converted ahead: the viewer reads the store itself, with range requests, and a subsample it opens (Session › Subsampling) reads only its cells, the first rows of every array in priority order. A store holding an array stored with a codec the browser cannot decode (an HDF5 filter such as szip or scale-offset; a Zarr codec such as Snappy or zfpy) is decoded on the kernel, chunk by chunk as the viewer asks.
Exact parameters#
show_anndata(...) has a closed signature. It accepts data and height,
followed by these exact keyword-only parameters:
web_source_url: where the browser loads the Cellucid app from (https://www.cellucid.comby default; the address of a copy you host, without a trailing slash)latent_key: theobsmlatent space (scVI, PCA) the outlier filter measures in;Nonetakes the store’s own default, else no latent space (the filter then waits for one chosen in Data mapping)gene_id_column:Noneusesvar.index(raw.var.indexwithexpression_source="raw"); any non-blank string names that exact column (default:None). Empty values take the index name and repeated names are made unique (MATR3,MATR3-1)obs_keys: exactobscolumns the viewer shows, in this order;None(the default) shows every columnnormalize_embeddings: whether the viewer scales embeddings into its coordinate range (defaultNone: the viewer’s default, which scales them)centroid_min_points: the fewest cells a category needs to get outlier quantiles (defaultNone: 10)embedding_keys: theobsmarray a dimension shows by default, such as{"2d": "X_tsne"}or{"2d": ("X_pca", [0, 1])}with 0-based columns (default:None, which usesX_umap_1d/X_umap_2d/X_umap_3dor a bareX_umap, then a deterministic best guess)knn_key: theobspmatrix the viewer draws as the neighbor graph, such as"connectivities"(default:None:obsp['connectivities']when present). The graph is read only when a view draws itexpression_source: where expression is read:"X","raw", or"layer:<name>"(defaultNone:X, or withoutXraw, then a layer, reported as guessed)dataset_name: non-empty, unpadded label without control characters; human-readable Unicode is preserved. Defaults to the store’suns['cellucid']['name']; required when it has nonedataset_id: portable 1–180 character ASCII identity (letter or digit first; then letters, digits,.,_, or-; no trailing.or reserved Windows device name), important for sessions; see Dataset identity (why it matters). Defaults to the store’suns['cellucid']['id']; required when it has nonevector_field_default: the vector field the overlay opens on: the<field>of anobsm['<field>_<basis>']array drawn overobsm['X_<basis>']("velocity"forvelocity_umap)modality: the modality a MuData dataset opens on (defaultNone: its default modality); see Multimodal data: MuData and the Modality select
Every option you name is checked against the store’s metadata before the
widget is shown — a key that does not exist raises at once — and passed to the
viewer as a launch default. An option left at None is taken from the store’s
own uns['cellucid']['defaults'], else from the viewer’s default rule (see
Data mapping).
Tip
Use the same exact dataset_id whenever you reopen the same dataset generation.
from cellucid import show_anndata
# With options
viewer = show_anndata(
"data.h5ad",
height=700,
latent_key="X_pca",
gene_id_column="gene_symbols",
dataset_name="PBMC demo",
dataset_id="pbmc-demo",
)
Which obsm key becomes the embedding#
X_umap_1d / X_umap_2d / X_umap_3d each decide their own dimension. An
object declaring none of them is shown from a bare X_umap at the dimension its
column count states, so a Scanpy object needs no rename. Full rules, including
what an unreadable width reports:
How the viewer reads AnnData (show_anndata() / serve_anndata()).
The neighbor graph is read when it is drawn#
The viewer shows obsp['connectivities'] as the neighbor graph when the store
has one, and reads it only when a view draws connectivity edges or the KNN drag
tool is used. To open on another graph, name it:
viewer = show_anndata(
"data.h5ad",
dataset_name="My study",
dataset_id="my-study-v1",
knn_key="connectivities",
)
Naming a matrix the store does not have raises before the viewer is shown:
knn_key names obsp['connectivities'], which this store does not have. obsp holds: []. Compute the neighbour graph with sc.pp.neighbors(adata), or leave knn_key unset.
A graph is checked when it is read: it must be symmetric, so the distances
matrix sc.pp.neighbors() also writes is refused. Any other obsp graph can be
chosen later under Session › Data mapping › Neighbour graph
(Data mapping).
Vector Fields in Notebooks (Velocity/Drift Overlay)#
Vector fields are optional per-cell displacement vectors (e.g. RNA velocity, drift, directed transitions) that Cellucid can render as an animated overlay on top of your embedding.
This matters for data loading because:
vector fields must be aligned to the same cells and same embedding basis/dimension
for
show_anndata(), everyobsmarray named<field>_<basis>after an embeddingX_<basis>with the same number of columns is offered as the field<field>, and is read only when the overlay shows it
Quick checklist (AnnData)#
To make a vector field appear when using show_anndata(...), you need:
An embedding in
adata.obsmnamedX_<basis>(X_umap,X_umap_3d,X_pca,X_branches, …) that a view showsA vector field in
adata.obsmnamed<field>_<basis>after it (scVelo’s convention:velocity_umapforX_umap)The vector array shape must be
(n_cells, n_columns), with exactly as many columns as the embeddingIf the store declares more than one field, the overlay opens on the one you name with
vector_field_default="velocity"(or, for the forward-drift example,vector_field_default="T_fwd")
Naming convention (scVelo’s <field>_<basis>)#
Cellucid detects vector fields in adata.obsm by the key <field>_<basis>
beside the embedding X_<basis>, for any basis:
velocity_umap(shape(n_cells, 2), over a 2-columnX_umap)velocity_umap_3d(shape(n_cells, 3), overX_umap_3d)T_fwd_umap(shape(n_cells, 2), overX_umap)velocity_branches(overX_branches, as many columns as it)
The keys of one field on every embedding are one field: velocity_umap and
velocity_umap_3d are both listed as velocity. A velocity_umap_2d beside a
bare X_umap (no X_umap_2d) is not detected; name it velocity_umap.
For full expectations (files, catalogs and uns['cellucid']), see File format expectations.
Minimal example: attach a 2D vector field#
import numpy as np
# A complete synthetic counter-clockwise field for an interface check.
# Do not interpret this constructed field as biological velocity.
coordinates = np.asarray(adata.obsm["X_umap"], dtype=np.float32) # 2 columns
offsets = coordinates - coordinates.mean(axis=0, keepdims=True)
adata.obsm["velocity_umap"] = np.column_stack(
(-offsets[:, 1], offsets[:, 0])
).astype(np.float32)
viewer = show_anndata(
adata,
dataset_name="My study",
dataset_id="my-study-v1",
)
Example: compute a drift field from a transition matrix (CellRank-style)#
If you have a transition matrix T and UMAP coordinates, Cellucid ships helpers:
from cellucid import add_transition_drift_to_obsm
# Adds "T_fwd_umap" (<field_prefix>_<basis>) into adata.obsm, as wide as X_umap
out_key = add_transition_drift_to_obsm(
adata,
T,
basis="umap",
field_prefix="T_fwd",
normalize_rows=False,
)
print("Wrote:", out_key)
viewer = show_anndata(
adata,
dataset_name="My study",
dataset_id="my-study-v1",
)
Verify that the viewer bound the vector fields#
Once the viewer shows the data, the data mapping it reports lists every bound field. In a later cell:
print(viewer.data_mapping["vectorFields"])
If the overlay is missing in the UI and the list is empty:
double-check your
adata.obsmkeys and shapesconfirm you have a matching embedding dimension (2D vs 3D)
see Troubleshooting (data loading) and Troubleshooting (velocity overlay)
Programmatic Control (Python → Viewer)#
Once you have a viewer, you can drive UI state from Python: everything a
person can do in the viewer is an action, and every action is a method (each
a step of the viewer’s history, labelled as the notebook’s).
Common actions:
# Highlight a few cells (0-based rows of your dataset, a boolean mask, or obs names)
viewer.highlight_cells(cells=[0, 10, 42], color="#ff0000")
# Color by an obs column (must exist in the dataset)
viewer.color_by(field={"source": "obs", "key": "cell_type"})
# Hide some cells (or visible=True to show them again; cells=None: every cell)
viewer.cells_visibility_set(cells=[0, 10, 42], visible=False)
# Reset the camera of the live view
viewer.camera_reset(view="live")
# Show a column you computed as a field, and a gene set
viewer.push(adata.obs["leiden"], key="leiden_notebook")
viewer.gene_set_save(name="T cell markers", genes=["CD3E", "CD3D", "CD2"])
Each returns a request at once; request.result() (in a notebook, in a later
cell) is the viewer’s answer, and raises with the viewer’s reason when it did
not apply the action. Reads work the same way: viewer.fields_list(),
viewer.history_get(), … (Python → frontend actions).
Actions sent inside a turn are one turn of the viewer’s history: the State panel shows them as one group, labelled as this notebook’s, and undoes them together:
with viewer.turn("Mark the clusters"):
viewer.color_by(field={"source": "obs", "key": "leiden"})
viewer.push(adata.obs["leiden"], key="leiden_notebook")
Important
Cell indices refer to the row order of the dataset Cellucid shows.
For a viewer constructed directly from
adata, this is the currentadatarow order.If you subset/shuffle
adatain Python, the indices will change.
Read and change the data mapping#
The data mapping says which stored arrays the viewer plots and reads: the
embedding of each dimension slot, the bound vector fields, and for an AnnData
source the neighbour graph, the gene names, the expression matrix and the
latent space. The viewer reports it after the dataset loads, and
viewer.data_mapping holds the latest report (read it in a later cell):
viewer.data_mapping["embeddings"]
# {'1d': None, '2d': {'key': 'X_umap', 'columns': [0, 1]}, '3d': None}
viewer.data_mapping_origin # "default", "best_guess", "session", "user" or "notebook"
To change it, name the arrays at launch (embedding_keys=, knn_key=,
expression_source=, …) or use Session › Data mapping in the viewer.
viewer.mapping_set(...) (the mapping.set action) changes the groups you
name and leaves the others as they are, without waiting, in a notebook widget
and a browser-tab viewer alike:
viewer.mapping_set(
embeddings={
"enabled": [{"key": "X_pca", "columns": [0, 1]}],
"defaults": {"1d": None, "2d": {"key": "X_pca", "columns": [0, 1]}, "3d": None},
},
)
A group this action does not have raises TypeError; the applied mapping is
viewer.data_mapping in a later cell, and a mapping the viewer refuses — an
array that fails its load-time check, for example — fails the request with
the viewer’s reason.
Reacting to the UI (Hooks: Viewer → Python)#
The viewer can send events back to Python so your notebook can react to selection/hover/click, and to a command the viewer refused.
Supported hooks:
@viewer.on_ready@viewer.on_selection@viewer.on_hover@viewer.on_click@viewer.on_command_error(a command this notebook sent was rejected by the viewer)@viewer.on_data_mapping(the viewer resolved or changed which arrays it plots)@viewer.on_history_change(the viewer’s history changed: a step, undo or a jump, a note, a tracker, a loaded session)@viewer.on_message(raw debugging)
Hooks run when the kernel is idle (between cells).
Minimal: print selections#
@viewer.on_selection
def handle_selection(event):
print("Selected:", len(event["cells"]))
Practical: analyze the selected cells#
@viewer.on_selection
def analyze(event):
cells = event["cells"]
subset = adata[cells].copy()
print(subset)
# e.g. run Scanpy plots or downstream analysis on `subset`
Debug: print all messages#
@viewer.on_message
def debug(event):
print(event)
For more about hooks, see Jupyter Hooks System (Python ↔ Frontend).
Pulling Results into Python#
Hooks are great when you want reactive code. For “pull-style” workflows, read what the viewer holds in a later cell.
Everything you made, in one call#
Every highlight group, label field, cluster, score and gene set you made in the viewer is sent to Python as you make it. One call writes them all onto your AnnData:
summary = viewer.pull(adata)
summary.columns # obs columns written: cellucid_highlight__<id>, your labels, scores
adata.uns["cellucid"]["gene_sets"]
Related: viewer.selection (the last selection, with obs names),
viewer.subset(adata, "my group") (a highlight group as a new AnnData),
viewer.gene_sets(). Full reference:
Round trip (results back from the viewer, columns and gene sets into it).
viewer.state (live snapshot)#
viewer.state is a small, thread-safe snapshot of the latest events:
print(viewer.state.ready) # the ready event (or None before the data opened)
print(viewer.state.selection) # last selection event (or None)
print(viewer.state.hover) # last hover event (or None)
print(viewer.state.click) # last click event (or None)
Session (the history, durable)#
In a notebook, ask for the current .cellucid-session – the viewer’s whole
history – in one cell:
request = viewer.request_session()
and collect it as a Python object in the next:
session = request.result()
session.save("my-study.cellucid-session")
session.tracker().steps[["id", "label", "origin"]] # every step, by whom
# Apply the state the viewer shows to adata: a plan first, then the change
plan = session.apply(adata) # dry run: what would change, one row each
plan.table
report = plan.apply() # writes the rows into adata, records the apply
The plan lists every label, field, gene set and highlight group the step
holds, each write, unchanged, replace or not-applied (with why).
Applying the same step again changes nothing. A column you made yourself is
never replaced. Filters, colours, views and the camera are reported, not
applied. Each apply that writes is recorded in
adata.uns["cellucid"]["history_applied"] (cellucid.history_applied(adata)
reads it). viewer.load_session("my-study.cellucid-session") pushes a saved
file back into the viewer, its trackers beside yours.
Important
Session application names cells by original position (each cell’s row in the order the dataset’s cells were given, whatever order a file stores them in).
Only apply a session to the AnnData that produced it, in that original order
(a Cellucid-ready file read with anndata: cellucid.restore_original_order(adata) first).
Debugging: viewer.debug_connection()#
If hooks or results seem “stuck”, run in a new cell:
report = viewer.debug_connection()
report
It waits for nothing and reports where the browser loads the app from
(web_source_url), which outputs are connected (connected_outputs),
whether the viewer opened its channel (channel_connected), the dataset it
serves, the viewer’s ready state, and the recent events by type with the
commands the viewer refused (recent_events["command_errors"]).
viewer.debug_connection() returns a dictionary; it prints nothing by itself.
Cleanup (Do This If You Re-run Cells Often)#
Each viewer holds its dataset (for an in-memory object, the store it serves
and the files a cell-major X was laid out in) until it is stopped.
viewer.stop() # freeze the output on its last view; release what it served
Note
viewer.stop() requests an exact freeze, stops answering the viewer,
unregisters hooks, and reports any failure. After a successful stop, the last
frame remains visible but is non-interactive.
Stop everything created in this kernel#
from cellucid.jupyter import cleanup_all
cleanup_all()
Remote / HPC Notebooks#
A notebook whose kernel runs elsewhere (a JupyterHub, a notebook on an HPC
compute node, VS Code Remote-SSH, Colab, a cloud VM) needs nothing extra:
show_anndata() reaches the kernel through the notebook’s own connection,
whichever way your browser reaches the notebook.
You need SSH forwarding only for a browser-tab viewer (AnnDataViewer,
serve_anndata, cellucid serve) started on a remote machine:
Start the viewer with a fixed port:
from cellucid import AnnDataViewer viewer = AnnDataViewer( "data.h5ad", port=8765, dataset_name="My study", dataset_id="my-study-v1", ) print(viewer.viewer_url)
On your laptop, forward that port to the remote machine:
ssh -N -L 8765:127.0.0.1:8765 <user>@<remote-host>
Now, when your browser loads http://127.0.0.1:8765/?viewer=true&...,
it hits your laptop’s 127.0.0.1:8765, which SSH forwards to the remote server.
Important
Without port=, AnnDataViewer takes 8765, or the next free port when another
viewer holds it. A tunnel forwards one fixed port, so name it with port=: a
viewer given a port uses that port or fails.
On an HPC compute node reached only through a login node, the forward is
opened by the login node (ssh -N -L 8765:compute-node:8765 you@login-node),
so the server must accept a connection from another machine; AnnDataViewer
always binds loopback, so start a server from the kernel instead:
from cellucid import serve_anndata
serve_anndata(
"data.h5ad",
port=8765,
host="0.0.0.0",
open_browser=False,
dataset_name="My study",
dataset_id="my-study-v1",
)
The address you open stays http://127.0.0.1:8765/?anndata=true. The full
recipe is in
Remote servers, SSH tunneling, and cloud.
Common Edge Cases#
The browser cannot reach
https://www.cellucid.com(offline laptop, blocked address): the output says the app did not load. Host a copy of the app and passweb_source_url=.The notebook does not show widgets: install
anywidgetwhere the notebook server runs, or use a front end that shows widgets.Showing one widget twice (ending another cell with
viewer): the newest output takes the viewer over; the older one says it was opened elsewhere.Kernel restarts: the output keeps its last view and says the notebook stopped it. Re-run the cell.
Huge in-memory
AnnData: its arrays are copied once into memory-mapped files (disk for one uncompressed copy of them, never a second copy in memory; a sparseXlaid out gene-major), so later edits to the object do not show. Open a subsample (Session › Subsampling) to read only some of the cells.
Troubleshooting#
Symptom: “The viewer doesn’t appear (empty output cell)”#
Likely causes (ordered)
The notebook front end does not show widgets (
anywidgetmissing where the notebook server runs).You are not in a notebook:
show_anndata()raises and names the browser-tab alternatives.In marimo, the widget is not the cell’s output.
Fix
Install
anywidgetin the notebook server’s environment and reload the page.In marimo, end the cell with
viewer.Outside a notebook, use
AnnDataViewer(...)orcellucid serve.
Symptom: “The output says the app did not load”#
Likely cause: the browser cannot load https://www.cellucid.com.
Fix: allow it, or host a copy of the app and pass web_source_url=.
Symptom: “A call raised: … a notebook widget hears the viewer only between cells”#
wait_for_ready(), wait_for_event() and a session request’s result()
wait for the viewer’s answer, which cannot arrive while the cell runs. Read
viewer.state or viewer.results in a later cell, register a hook, or for a
session collect request.result() (or
viewer.apply_session_to_anndata(adata, request), the plan) in the next cell.
Symptom: “The viewer says Cellucid found nothing to plot”#
Likely causes
No
obsmarray can be plotted: each dense one has another row count than the cells, places no cell (every row non-finite), or holds a value past float32. (Anobsmwith no dense numeric array at all is refused as having no cell embedding: Symptom: “… has no cell embedding, and Cellucid draws cells at embedding coordinates”.)
How to confirm
print(adata.obsm.keys())Read the message the viewer shows: it lists every
obsmentry and why it was not used.
Fix
Compute UMAP (
sc.tl.umap(adata)writes a two-columnX_umap, which is read as 2D as written), orname a usable array explicitly:
show_anndata(adata, embedding_keys={"2d": "X_tsne"}, dataset_name=..., dataset_id=...).If you show a
.h5ador Zarr store path rather than an in-memory object, re-save the file after a repair.
Symptom: “The KNN graph is there, but there is no edge overlay”#
Likely causes
The store’s graph is not named
connectivities, so the viewer did not pick it, or the store’suns['cellucid']['defaults']names no graph.The graph was refused when it was read (not symmetric, such as
distances).
How to confirm (in a later cell)
print(viewer.data_mapping["knn"])
None means no neighbor graph is chosen.
Fix
Choose the graph under Session › Data mapping › Neighbour graph, or recreate the viewer with
knn_key="<obsp key>".
Symptom: “The cell runs for a long time before the viewer appears”#
Likely causes
You passed an in-memory
MuData, and it is being written to a temporary.h5mu.You passed an in-memory
AnnDatawith a large cell-majorX, and the first gene waits for its gene-major layout (made in the background from the start).
Fix
Pass the path of a saved
.h5ador Zarr store, which is read in place.Save the store without the undecodable codec (gzip, or no compression).
Symptom: “Vector field overlay is missing (no toggle / no fields)”#
Likely causes
Your vectors are not stored under a detected key (e.g. missing
_umapsuffix).Shape mismatch: vectors are
(n_cells, 2)but you are viewing in 3D (or vice versa).You have vectors, but they don’t match
adata.n_obs(filtered/shuffled mismatch).
How to confirm (in a later cell)
print(viewer.data_mapping["vectorFields"])
Fix
Follow the naming rules in Vector Fields in Notebooks (Velocity/Drift Overlay) (and File format expectations).
If you have both 2D and 3D points, make sure you provide the matching vector dimension.
Symptom: “Gene search returns nothing / the wrong gene names”#
Likely causes
Your genes were named from
var_names, which holds a vocabulary you are not searching in.The names you want are in a different
varcolumn.
Fix
Pass
gene_id_column="..."toshow_anndata().Or write the file with
prepare(var_gene_id_column="..."), which records it.
Symptom: “Hooks don’t fire (selection events never reach Python)”#
Likely causes
A cell is still running: the widget’s events arrive when the kernel is idle.
The selection was not confirmed.
The output belongs to an older viewer (re-run cells, a kernel restart).
How to confirm
Register a raw handler:
@viewer.on_message def debug(event): print(event)
Check
viewer.debug_connection()["channel_connected"]in a new cell.
Fix
Let the running cell finish; re-run the cell that creates the viewer for a stale output.
Symptom: “The first gene takes noticeably longer than the next ones”#
Explanation
The viewer reads a gene from the store itself. For a row-major (CSR) sparse matrix it first builds a gene index over the stored entries, once per matrix; every later gene reads only its own entries.
Fix
Nothing is needed. To make the first gene as fast as the rest, save the matrix column-major (CSC); an in-memory
AnnDatais served that way already.
Next Steps#
Stable dataset identity + sessions: Dataset identity (why it matters)
File/key expectations (including vector fields): File format expectations
General loading troubleshooting: Troubleshooting (data loading)
Vector field overlay usage: Vector Field / Velocity Overlay (GPU Particle Overlay)
Hooks deep dive: Jupyter Hooks System (Python ↔ Frontend)
Runnable Pancreas notebook: Jupyter embedding, hooks, and sessions
If you want to share datasets publicly: a dataset catalog on GitHub or a static host (Share Without Running a Server (Publish a Dataset Catalog))