Skip to content

Commit 9c2ba60

Browse files
authored
Fix clustermap (#163)
1 parent cadd070 commit 9c2ba60

2 files changed

Lines changed: 199 additions & 26 deletions

File tree

src/hv_anndata/plotting/clustermap.py

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
from typing import TYPE_CHECKING, TypedDict
67

78
import anndata as ad
@@ -61,8 +62,12 @@ def create_clustermap_plot(
6162
if hasattr(x, "toarray"):
6263
x = x.toarray()
6364

65+
# Take var_names from the same space as x: adata.raw has a different
66+
# (usually larger) gene set than adata.var, so mixing them makes the
67+
# variance-ranked indices overrun adata.var_names.
68+
var_names = adata.raw.var_names if use_raw else adata.var_names
69+
6470
# Filter genes if max_genes is specified
65-
var_names = adata.var_names
6671
if max_genes is not None and len(var_names) > max_genes:
6772
gene_vars = np.var(x, axis=0)
6873
top_gene_indices = np.argsort(gene_vars)[-max_genes:]
@@ -97,10 +102,12 @@ def create_clustermap_plot(
97102
"show_grid": False,
98103
"tools": ["hover"],
99104
"colorbar": True,
105+
"responsive": True,
100106
}
101107

102108
return clustered_plot.opts(
103-
hv.opts.HeatMap(**main_plot_opts), hv.opts.Dendrogram(xaxis=None, yaxis=None)
109+
hv.opts.HeatMap(**main_plot_opts),
110+
hv.opts.Dendrogram(xaxis=None, yaxis=None, responsive=True),
104111
)
105112

106113

@@ -146,12 +153,19 @@ def __init__(self, adata: AnnData | None = None, **params: object) -> None:
146153
"""Initialize the ClusterMap with the given parameters."""
147154
if adata is not None:
148155
params["adata"] = adata
149-
super().__init__(**params)
150156

151-
# Check if raw data exists
152-
has_raw = self.adata.raw is not None
153-
if self.use_raw is None:
154-
self.use_raw = has_raw
157+
# Resolve use_raw's default (if not given, or given as None) before
158+
# calling super().__init__ so it's set as part of construction rather
159+
# than as a later attribute assignment. A later assignment would fire
160+
# the async _update_plot watcher below, which relies on a running
161+
# event loop to schedule -- not guaranteed outside of a live Panel
162+
# session (e.g. plain scripts and tests), where it has been observed
163+
# to leak an event loop/socket.
164+
has_raw = adata is not None and adata.raw is not None
165+
if params.get("use_raw") is None:
166+
params["use_raw"] = has_raw
167+
168+
super().__init__(**params)
155169

156170
use_raw_widget = pmui.widgets.Checkbox.from_param(
157171
self.param.use_raw,
@@ -160,6 +174,12 @@ def __init__(self, adata: AnnData | None = None, **params: object) -> None:
160174
disabled=not has_raw, # Disable if no raw data
161175
)
162176

177+
# Shown immediately; later recomputes (triggered by widget changes,
178+
# which only ever happen inside a running Panel session) go through
179+
# the async _update_plot watcher, which swaps in the finished plot or
180+
# an error message behind a loading indicator.
181+
self._plot_placeholder = pn.pane.Placeholder(width=300, height=300)
182+
163183
self._widgets = pmui.Column(
164184
pmui.widgets.Select.from_param(
165185
self.param.cmap,
@@ -175,29 +195,49 @@ def __init__(self, adata: AnnData | None = None, **params: object) -> None:
175195
visible=self.param.show_widgets,
176196
sx={"border": 1, "borderColor": "#e3e3e3", "borderRadius": 1},
177197
sizing_mode="stretch_width",
178-
max_width=400,
198+
max_width=300,
179199
)
180200

181-
@param.depends("use_raw", "cmap", "max_genes", "plot_opts")
182-
def _plot_view(self) -> pn.viewable.Viewable:
183-
"""Create the plot view with parameter dependencies."""
201+
# Build the first plot directly and synchronously; see the use_raw
202+
# comment above for why this doesn't go through _update_plot.
203+
self._plot_placeholder.object = self._build_plot_or_error()
204+
205+
def _build_plot_or_error(self) -> pn.viewable.Viewable:
206+
"""Build the clustermap pane, or an Alert describing why it failed."""
184207
config = ClusterMapConfig(
185208
cmap=self.cmap,
186209
)
187-
188-
plot = create_clustermap_plot(
189-
self.adata,
190-
use_raw=self.use_raw,
191-
max_genes=self.max_genes,
192-
**config,
193-
)
194-
195-
# Apply user-provided plot options to the HeatMap only
196-
if self.plot_opts:
197-
plot = plot.opts(hv.opts.HeatMap(**self.plot_opts))
198-
199-
return plot
210+
try:
211+
plot = create_clustermap_plot(
212+
self.adata,
213+
use_raw=self.use_raw,
214+
max_genes=self.max_genes,
215+
**config,
216+
)
217+
if self.plot_opts:
218+
plot = plot.opts(hv.opts.HeatMap(**self.plot_opts))
219+
return pn.pane.HoloViews(
220+
plot, sizing_mode="stretch_both", min_height=550, min_width=450
221+
)
222+
except Exception as e: # noqa: BLE001
223+
msg = f"Could not render clustermap: {e}"
224+
if pn.state.notifications is not None:
225+
pn.state.notifications.error(msg)
226+
return pn.pane.Alert(msg, alert_type="danger", sizing_mode="stretch_width")
227+
228+
@param.depends("use_raw", "cmap", "max_genes", "plot_opts", watch=True)
229+
async def _update_plot(self) -> None:
230+
"""Recompute the clustermap and swap it into the placeholder."""
231+
# Hold the loading overlay across the whole update, including the object
232+
# assignment, so it doesn't vanish while the plot is still being built
233+
# and serialized. Yield first so it paints, and run the slow clustering
234+
# off the event loop.
235+
with self._plot_placeholder.param.update(loading=True):
236+
await asyncio.sleep(0.01)
237+
self._plot_placeholder.object = await asyncio.to_thread(
238+
self._build_plot_or_error
239+
)
200240

201241
def __panel__(self) -> pn.viewable.Viewable:
202242
"""Create the Panel application layout."""
203-
return pmui.Row(self._widgets, self._plot_view)
243+
return pmui.Row(self._widgets, self._plot_placeholder)

tests/plotting/test_clustermap.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
57
import anndata as ad
68
import numpy as np
79
import pandas as pd
10+
import panel as pn
811
import panel_material_ui as pmui
912
import pytest
1013
import scanpy as sc
1114

12-
from hv_anndata import ClusterMap
15+
from hv_anndata import ClusterMap, create_clustermap_plot
1316

1417

1518
@pytest.fixture
@@ -68,8 +71,138 @@ def test_clustermap_no_raw_data() -> None:
6871
assert cm.use_raw is False # Should default to False when no raw data
6972

7073

74+
@pytest.mark.usefixtures("bokeh_renderer")
75+
def test_clustermap_builds_plot_synchronously_on_construction(
76+
sadata: ad.AnnData,
77+
) -> None:
78+
"""Construction alone, with no async machinery involved, must render the plot.
79+
80+
Building the first plot via the async _update_plot watcher instead would
81+
depend on a running event loop to schedule it -- not guaranteed outside a
82+
live Panel session (e.g. plain scripts and tests) -- and was observed to
83+
leak an event loop/socket when construction happened outside of one;
84+
see #163.
85+
"""
86+
cm = ClusterMap(adata=sadata)
87+
88+
assert isinstance(cm._plot_placeholder.object, pn.pane.HoloViews)
89+
90+
91+
@pytest.mark.usefixtures("bokeh_renderer")
92+
def test_clustermap_explicit_use_raw_builds_plot_synchronously(
93+
sadata: ad.AnnData,
94+
) -> None:
95+
"""Passing use_raw explicitly must not require a later trigger to render."""
96+
cm = ClusterMap(adata=sadata, use_raw=True)
97+
98+
assert cm.use_raw is True
99+
assert isinstance(cm._plot_placeholder.object, pn.pane.HoloViews)
100+
101+
71102
@pytest.mark.usefixtures("bokeh_renderer")
72103
def test_integration() -> None:
73104
adata = sc.datasets.pbmc68k_reduced() # errors
74105

75106
assert ClusterMap(adata=adata).__panel__()
107+
108+
109+
@pytest.fixture
110+
def sadata_mismatched_raw() -> ad.AnnData:
111+
"""AnnData whose raw layer has far more genes than the primary var.
112+
113+
Used to exercise the max_genes filtering path with use_raw=True, where
114+
gene-variance indices are computed over adata.raw.X's (larger) gene axis.
115+
116+
Returns
117+
-------
118+
AnnData with 3 main-var genes and 20 raw-var genes.
119+
120+
"""
121+
n_obs = 10
122+
n_vars = 3
123+
n_raw_vars = 20
124+
125+
rng = np.random.default_rng()
126+
127+
x = rng.random((n_obs, n_vars))
128+
obs = pd.DataFrame(
129+
{"cell_type": ["A", "B"] * (n_obs // 2)},
130+
index=[f"cell_{i}" for i in range(n_obs)],
131+
)
132+
var = pd.DataFrame(index=[f"gene_{i}" for i in range(n_vars)])
133+
raw_x = rng.random((n_obs, n_raw_vars))
134+
raw_var = pd.DataFrame(index=[f"raw_gene_{i}" for i in range(n_raw_vars)])
135+
adata = ad.AnnData(X=x, obs=obs, var=var)
136+
adata.raw = ad.AnnData(X=raw_x, var=raw_var, obs=obs)
137+
return adata
138+
139+
140+
@pytest.mark.usefixtures("bokeh_renderer")
141+
def test_create_clustermap_plot_use_raw_var_names_from_raw(
142+
sadata_mismatched_raw: ad.AnnData,
143+
) -> None:
144+
"""max_genes filtering with use_raw must index raw.var_names, not var_names.
145+
146+
Pre-fix this raised an IndexError: the filter indices are computed from
147+
adata.raw.X's (larger) gene axis but were used to index the (shorter)
148+
adata.var_names; see #163.
149+
"""
150+
plot = create_clustermap_plot(sadata_mismatched_raw, use_raw=True, max_genes=10)
151+
152+
genes = set(plot.dimension_values("variable", expanded=False))
153+
assert len(genes) == 10
154+
assert all(gene.startswith("raw_gene_") for gene in genes)
155+
156+
157+
@pytest.mark.usefixtures("bokeh_renderer")
158+
def test_create_clustermap_plot_heatmap_is_responsive(
159+
sadata_mismatched_raw: ad.AnnData,
160+
) -> None:
161+
"""The HeatMap must be responsive so it doesn't overflow at large gene counts."""
162+
plot = create_clustermap_plot(sadata_mismatched_raw, use_raw=True, max_genes=10)
163+
164+
assert plot.main.opts.get("plot").kwargs["responsive"] is True
165+
166+
167+
@pytest.mark.usefixtures("bokeh_renderer")
168+
# asyncio.run() spins up and tears down a fresh loop; Panel/Bokeh's IOLoop and
169+
# thread-pool teardown from asyncio.to_thread emit ResourceWarnings on gc that
170+
# land here (or on whatever test runs next), unrelated to this test's outcome.
171+
@pytest.mark.filterwarnings("ignore::ResourceWarning")
172+
def test_clustermap_update_plot_use_raw_with_mismatched_var_names(
173+
sadata_mismatched_raw: ad.AnnData,
174+
) -> None:
175+
"""The ClusterMap widget must render, not crash, through the same code path."""
176+
cm = ClusterMap(adata=sadata_mismatched_raw, use_raw=True, max_genes=10)
177+
178+
asyncio.run(cm._update_plot())
179+
180+
assert isinstance(cm._plot_placeholder.object, pn.pane.HoloViews)
181+
assert cm._plot_placeholder.loading is False
182+
183+
184+
@pytest.mark.usefixtures("bokeh_renderer")
185+
@pytest.mark.filterwarnings("ignore::ResourceWarning")
186+
def test_clustermap_update_plot_error_sets_alert(
187+
sadata: ad.AnnData, monkeypatch: pytest.MonkeyPatch
188+
) -> None:
189+
"""Errors during recompute must surface as an Alert, not propagate.
190+
191+
The recompute runs off the main thread via asyncio.to_thread, so an
192+
unhandled exception there would otherwise be silently lost; see #163.
193+
"""
194+
195+
def _boom(*_args: object, **_kwargs: object) -> None:
196+
msg = "kaboom"
197+
raise ValueError(msg)
198+
199+
cm = ClusterMap(adata=sadata)
200+
monkeypatch.setattr("hv_anndata.plotting.clustermap.create_clustermap_plot", _boom)
201+
202+
asyncio.run(cm._update_plot())
203+
204+
alert = cm._plot_placeholder.object
205+
assert isinstance(alert, pn.pane.Alert)
206+
assert alert.alert_type == "danger"
207+
assert "Could not render clustermap" in alert.object
208+
assert cm._plot_placeholder.loading is False

0 commit comments

Comments
 (0)