diff --git a/src/squidpy/_docs.py b/src/squidpy/_docs.py index ab21189c2..0eab11bca 100644 --- a/src/squidpy/_docs.py +++ b/src/squidpy/_docs.py @@ -442,9 +442,20 @@ def decorator2(obj: Any) -> Any: A dictionary mapping element names of the SpatialData object to coordinate systems. The elements can be either Shapes or Labels. For compatibility, the spatialdata table must annotate all regions keys. Must not be ``None`` if ``adata`` is a :class:`spatialdata.SpatialData`. + With ``coordinate_source="table"``, the element names are used to check table associations and instance + order. Coordinates are read as stored; the coordinate-system values do not trigger a transformation. table_key Key in :attr:`spatialdata.SpatialData.tables` where the spatialdata table is stored. Must not be ``None`` if - ``adata`` is a :class:`spatialdata.SpatialData`.""" + ``adata`` is a :class:`spatialdata.SpatialData`. +coordinate_source + Source of coordinates for :class:`spatialdata.SpatialData` inputs. ``"elements"`` (default) computes + element centroids in the mapped coordinate systems and stores them under ``spatial_key``. + ``"table"`` reads the selected table's existing ``obsm[spatial_key]`` coordinates, skipping centroid + computation and coordinate transformation. Supply a real numeric NumPy array of shape ``(n_obs, 2)`` + with finite values and rows in table observation order. The coordinates must already be in the desired + coordinate system, with units appropriate for the graph builder. Missing or invalid coordinates raise + an error without falling back to element centroids. Both values use existing ``obsm[spatial_key]`` + coordinates for :class:`anndata.AnnData` inputs.""" _graph_common_params = """\ percentile Percentile of the distances to use as threshold. @@ -463,7 +474,14 @@ def decorator2(obj: Any) -> Any: {_n_jobs_thread_rules} Speedup is sub-linear (memory-bandwidth bound), so parallelism mainly pays off for many large libraries.""" -_spatial_neighbors_returns = """\ +_spatial_neighbors_copy_notes = """\ +For SpatialData inputs, ``copy=True`` builds the graph in a lightweight temporary AnnData and leaves +the source table unchanged. The expression matrix is neither copied nor loaded. Table mode gives the +temporary object its own copy of the coordinates. With ``copy=False``, graph results are saved to the +selected table. Element mode writes computed coordinates to ``obsm[spatial_key]``; table mode uses +the existing coordinates.""" +_spatial_neighbors_returns = ( + """\ If ``copy = True``, returns a :class:`~squidpy.types.SpatialNeighborsResult` with the spatial connectivities and distances matrices. @@ -471,7 +489,11 @@ def decorator2(obj: Any) -> Any: - :attr:`anndata.AnnData.obsp` ``['{key_added}_connectivities']`` - the spatial connectivities. - :attr:`anndata.AnnData.obsp` ``['{key_added}_distances']`` - the spatial distances. - - :attr:`anndata.AnnData.uns` ``['{key_added}']`` - :class:`dict` containing parameters.""" + - :attr:`anndata.AnnData.uns` ``['{key_added}']`` - :class:`dict` containing parameters. + +""" + + _spatial_neighbors_copy_notes +) d = DocstringProcessor( adata=_adata, @@ -532,4 +554,5 @@ def decorator2(obj: Any) -> Any: graph_common_params=_graph_common_params, n_jobs_libraries=_n_jobs_libraries, spatial_neighbors_returns=_spatial_neighbors_returns, + spatial_neighbors_copy_notes=_spatial_neighbors_copy_notes, ) diff --git a/src/squidpy/gr/_build.py b/src/squidpy/gr/_build.py index ea5763bc5..6437de80f 100644 --- a/src/squidpy/gr/_build.py +++ b/src/squidpy/gr/_build.py @@ -150,6 +150,7 @@ def spatial_neighbors( adata: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -182,15 +183,7 @@ def spatial_neighbors( ---------- %(adata)s %(spatial_key)s - If `adata` is a :class:`spatialdata.SpatialData`, the coordinates of the centroids will be stored in the - `adata` with this key. - elements_to_coordinate_systems - A dictionary mapping element names of the SpatialData object to coordinate systems. - The elements can be either Shapes or Labels. For compatibility, the spatialdata table must annotate - all regions keys. Must not be `None` if `adata` is a :class:`spatialdata.SpatialData`. - table_key - Key in :attr:`spatialdata.SpatialData.tables` where the spatialdata table is stored. Must not be `None` if - `adata` is a :class:`spatialdata.SpatialData`. + %(sdata_params)s %(library_key)s coord_type Type of coordinate system. Valid options are: @@ -307,6 +300,8 @@ def spatial_neighbors( - :attr:`anndata.AnnData.obsp` ``['{key_added}_connectivities']`` - the spatial connectivities. - :attr:`anndata.AnnData.obsp` ``['{key_added}_distances']`` - the spatial distances. - :attr:`anndata.AnnData.uns` ``['{key_added}']`` - :class:`dict` containing parameters. + + %(spatial_neighbors_copy_notes)s """ warnings.warn( "Calling `spatial_neighbors` is deprecated and will be removed in squidpy " @@ -319,6 +314,7 @@ def spatial_neighbors( adata, library_key = _prepare_spatial_neighbors_input( adata, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -353,9 +349,12 @@ def _resolve_data( elements_to_coordinate_systems: dict[str, str] | None, table_key: str | None = None, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", library_key: str | None = None, copy: bool = False, ) -> tuple[AnnData, str | None]: + if coordinate_source not in ("elements", "table"): + raise ValueError(f"Invalid `coordinate_source` {coordinate_source!r}. Expected 'elements' or 'table'.") if not isinstance(data, SpatialData): return data, library_key @@ -385,27 +384,40 @@ def _resolve_data( elem_instances.append(element_instances) element_instances = pd.concat(elem_instances) - if (not np.all(element_instances.values == table.obs[instance_key].values)) or ( - not np.all(ordered_regions_in_table == regions) + if not np.array_equal(element_instances.values, table.obs[instance_key].values) or not np.array_equal( + ordered_regions_in_table, regions ): raise ValueError( "The spatialdata table must annotate all elements keys. Some elements are missing or not ordered correctly, please check the `elements_to_coordinate_systems` dictionary." ) - centroids = [] - for region_ in ordered_regions_in_table: - cs = elements_to_coordinate_systems[region_] - centroid = get_centroids(data[region_], coordinate_system=cs)[["x", "y"]].compute() + if coordinate_source == "table": + coords = table.obsm[spatial_key] + if not isinstance(coords, np.ndarray) or coords.dtype.kind not in "iuf": + raise TypeError(f"`table.obsm[{spatial_key!r}]` must be a real numeric NumPy array.") + if coords.shape != (table.n_obs, 2): + raise ValueError(f"`table.obsm[{spatial_key!r}]` must have shape ({table.n_obs}, 2), found {coords.shape}.") + if not np.isfinite(coords).all(): + raise ValueError(f"`table.obsm[{spatial_key!r}]` must contain only finite coordinates.") + if copy: + coords = coords.copy() + else: + centroids = [] + for region_ in ordered_regions_in_table: + cs = elements_to_coordinate_systems[region_] + centroid = get_centroids(data[region_], coordinate_system=cs)[["x", "y"]].compute() - # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 - if remove_centroids[region_]: - centroid = centroid[1:].copy() - centroids.append(centroid) + # TODO: remove this after https://github.com/scverse/spatialdata/issues/614 + if remove_centroids[region_]: + centroid = centroid[1:].copy() + centroids.append(centroid) + coords = np.concatenate(centroids) if copy: # Graph construction only needs observations, coordinates and the uns keys # used by legacy graph dispatch. Do not copy or load expression matrices. table = AnnData(obs=table.obs.copy(), uns=table.uns.copy()) - table.obsm[spatial_key] = np.concatenate(centroids) + if copy or coordinate_source == "elements": + table.obsm[spatial_key] = coords return table, region_key @@ -416,6 +428,7 @@ def spatial_neighbors_from_builder( *, builder: GraphBuilder[Any, Any], spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -471,6 +484,7 @@ def spatial_neighbors_from_builder( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -491,6 +505,7 @@ def _prepare_spatial_neighbors_input( data: AnnData | SpatialData, *, spatial_key: str, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None, table_key: str | None, library_key: str | None, @@ -500,6 +515,7 @@ def _prepare_spatial_neighbors_input( adata, library_key = _resolve_data( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -514,6 +530,7 @@ def spatial_neighbors_knn( data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -562,6 +579,7 @@ def spatial_neighbors_knn( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -584,6 +602,7 @@ def spatial_neighbors_radius( *, radius: float | tuple[float, float], spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -620,6 +639,20 @@ def spatial_neighbors_radius( ------- %(spatial_neighbors_returns)s + Examples + -------- + Reuse coordinates stored in a SpatialData table. The coordinates must already + be in the desired coordinate system and use the same units as ``radius``. + + >>> sq.gr.spatial_neighbors_radius( + ... sdata, + ... table_key="table", + ... elements_to_coordinate_systems={"cells": "global"}, + ... spatial_key="precomputed_spatial", + ... coordinate_source="table", + ... radius=40, + ... ) + See Also -------- spatial_neighbors_from_builder : Use :class:`~squidpy.gr.neighbors.RadiusBuilder` directly for advanced customization. @@ -634,6 +667,7 @@ def spatial_neighbors_radius( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -655,6 +689,7 @@ def spatial_neighbors_delaunay( data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -710,6 +745,7 @@ def spatial_neighbors_delaunay( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, @@ -731,6 +767,7 @@ def spatial_neighbors_grid( data: AnnData | SpatialData, *, spatial_key: str = Key.obsm.spatial, + coordinate_source: Literal["elements", "table"] = "elements", elements_to_coordinate_systems: dict[str, str] | None = None, table_key: str | None = None, library_key: str | None = None, @@ -799,6 +836,7 @@ def spatial_neighbors_grid( adata, library_key = _prepare_spatial_neighbors_input( data, spatial_key=spatial_key, + coordinate_source=coordinate_source, elements_to_coordinate_systems=elements_to_coordinate_systems, table_key=table_key, library_key=library_key, diff --git a/tests/graph/test_spatial_neighbors_coordinate_source.py b/tests/graph/test_spatial_neighbors_coordinate_source.py new file mode 100644 index 000000000..ae2de0483 --- /dev/null +++ b/tests/graph/test_spatial_neighbors_coordinate_source.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import inspect +from copy import deepcopy +from unittest.mock import PropertyMock, patch + +import numpy as np +import pandas as pd +import pytest +from anndata import AnnData, concat, read_h5ad +from scipy.sparse import csr_matrix +from spatialdata import SpatialData +from spatialdata.models import Labels2DModel, TableModel +from spatialdata.transformations import Scale, set_transformation + +from squidpy import gr +from squidpy.gr import _build +from squidpy.gr.neighbors import KNNBuilder + +pytestmark = pytest.mark.filterwarnings("ignore:Calling `spatial_neighbors` is deprecated:FutureWarning") + +INTERFACES = [ + pytest.param(gr.spatial_neighbors, {"coord_type": "generic", "n_neighs": 2}, id="legacy"), + pytest.param(gr.spatial_neighbors_knn, {"n_neighs": 2}, id="knn"), + pytest.param(gr.spatial_neighbors_radius, {"radius": 2.0}, id="radius"), + pytest.param(gr.spatial_neighbors_delaunay, {}, id="delaunay"), + pytest.param(gr.spatial_neighbors_grid, {"n_neighs": 4}, id="grid"), + pytest.param(gr.spatial_neighbors_from_builder, {"builder": KNNBuilder(n_neighs=2)}, id="builder"), +] + + +@pytest.fixture +def prepared_sdata(sdata_mask_graph): + sdata = sdata_mask_graph + coords = _build.get_centroids(sdata.shapes["circles"], coordinate_system="global")[["x", "y"]].compute() + sdata.tables["table"].obsm["spatial"] = coords.to_numpy() + return sdata + + +def assert_graph_equal(actual, expected): + for name in ("connectivities", "distances"): + np.testing.assert_allclose(getattr(actual, name).toarray(), getattr(expected, name).toarray()) + + +@pytest.mark.parametrize(("function", "kwargs"), INTERFACES) +@pytest.mark.parametrize("copy", [True, False]) +def test_table_coordinates_all_interfaces(prepared_sdata, mocker, function, kwargs, copy): + spatial_key = "precomputed_spatial" + parameter = inspect.signature(function).parameters["coordinate_source"] + assert parameter.kind == inspect.Parameter.KEYWORD_ONLY + assert parameter.default == "elements" + table = prepared_sdata.tables["table"] + coords = table.obsm.pop("spatial") + table.obsm[spatial_key] = coords + reference = AnnData(obs=table.obs.copy(), obsm={spatial_key: coords.copy()}) + expected = function(reference, spatial_key=spatial_key, library_key="region", copy=True, **kwargs) + before_obs, before_var, before_uns = table.obs.copy(), table.var.copy(), deepcopy(table.uns) + before_x = table.X.copy() + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Centroids must not be read")) + + result = function( + prepared_sdata, + table_key="table", + # The value need not name an existing coordinate system in table mode. + elements_to_coordinate_systems={"circles": "already_prepared"}, + coordinate_source="table", + spatial_key=spatial_key, + key_added="reused", + copy=copy, + **kwargs, + ) + + centroid.assert_not_called() + assert table.obsm[spatial_key] is coords + np.testing.assert_array_equal(table.X, before_x) + pd.testing.assert_frame_equal(table.obs, before_obs) + pd.testing.assert_frame_equal(table.var, before_var) + if copy: + assert_graph_equal(result, expected) + assert not table.obsp + assert table.uns == before_uns + else: + assert result is None + np.testing.assert_allclose(table.obsp["reused_connectivities"].toarray(), expected.connectivities.toarray()) + np.testing.assert_allclose(table.obsp["reused_distances"].toarray(), expected.distances.toarray()) + assert table.uns["reused_neighbors"]["connectivities_key"] == "reused_connectivities" + + +def test_default_elements_and_table_agree(prepared_sdata, mocker): + params = { + "table_key": "table", + "elements_to_coordinate_systems": {"circles": "global"}, + "copy": True, + "radius": 2.0, + } + centroid = mocker.spy(_build, "get_centroids") + expected = gr.spatial_neighbors_radius(prepared_sdata, **params) + assert centroid.call_count == 1 + centroid.reset_mock() + actual = gr.spatial_neighbors_radius(prepared_sdata, coordinate_source="table", **params) + centroid.assert_not_called() + assert_graph_equal(actual, expected) + + +def test_table_mode_does_not_transform_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + coords = table.obsm["spatial"].copy() + set_transformation( + prepared_sdata.shapes["circles"], Scale([10.0, 10.0], axes=("x", "y")), to_coordinate_system="scaled" + ) + params = {"table_key": "table", "elements_to_coordinate_systems": {"circles": "scaled"}, "radius": 2.0} + expected = gr.spatial_neighbors_radius(AnnData(obsm={"spatial": coords}), radius=2.0, copy=True) + gr.spatial_neighbors_radius(prepared_sdata, coordinate_source="table", **params) + np.testing.assert_allclose(table.obsp["spatial_distances"].toarray(), expected.distances.toarray()) + np.testing.assert_array_equal(table.obsm["spatial"], coords) + # The default still recomputes and transforms centroids, overwriting stored coordinates. + gr.spatial_neighbors_radius(prepared_sdata, **params) + np.testing.assert_allclose(table.obsm["spatial"], coords * 10.0) + assert table.obsp["spatial_connectivities"].nnz < expected.connectivities.nnz + + +def test_copy_isolates_coordinates_even_for_a_mutating_builder(prepared_sdata, mocker): + table = prepared_sdata.tables["table"] + coords = table.obsm["spatial"] + before = coords.copy() + builder = KNNBuilder(n_neighs=2) + original = builder.build_graph + + def mutate(received): + assert not np.shares_memory(received, coords) + received[:] += 100.0 + return original(received) + + mocker.patch.object(builder, "build_graph", side_effect=mutate) + run = mocker.spy(_build, "_run_spatial_neighbors") + gr.spatial_neighbors_from_builder( + prepared_sdata, + builder=builder, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + resolved = run.call_args.args[0] + assert resolved.X is None + assert not np.shares_memory(resolved.obsm["spatial"], coords) + np.testing.assert_array_equal(coords, before) + assert not table.obsp + + +@pytest.mark.parametrize("input_type", ["anndata", "spatialdata"]) +def test_invalid_source(prepared_sdata, input_type): + data = prepared_sdata if input_type == "spatialdata" else prepared_sdata.tables["table"] + with pytest.raises(ValueError, match="coordinate_source"): + gr.spatial_neighbors_knn(data, coordinate_source="auto", copy=True) + + +def test_anndata_sources_use_existing_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + builder = KNNBuilder(n_neighs=2) + expected = gr.spatial_neighbors_from_builder(table, builder=builder, copy=True) + actual = gr.spatial_neighbors_from_builder(table, builder=builder, coordinate_source="table", copy=True) + assert_graph_equal(actual, expected) + + +def test_missing_coordinates_do_not_fall_back(sdata_mask_graph, mocker): + centroid = mocker.patch.object(_build, "get_centroids") + with pytest.raises(KeyError, match="precomputed_spatial"): + gr.spatial_neighbors_knn( + sdata_mask_graph, + table_key="table", + spatial_key="precomputed_spatial", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + ) + centroid.assert_not_called() + assert not sdata_mask_graph.tables["table"].obsm + assert not sdata_mask_graph.tables["table"].obsp + + +def test_invalid_coordinate_dtype(prepared_sdata): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = table.obsm["spatial"].astype(str) + with pytest.raises(TypeError, match="real numeric NumPy array"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + + +def test_non_numpy_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = csr_matrix(table.obsm["spatial"]) + with pytest.raises(TypeError, match="real numeric NumPy array"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + + +@pytest.mark.parametrize("dtype", [np.int32, np.uint32]) +def test_real_numeric_dtypes(prepared_sdata, dtype): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = np.arange(table.n_obs * 2).reshape(table.n_obs, 2).astype(dtype) + result = gr.spatial_neighbors_knn( + prepared_sdata, + n_neighs=2, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + assert result.connectivities.nnz == table.n_obs * 2 + + +@pytest.mark.parametrize("trailing_shape", [(3,), (2, 1)]) +def test_invalid_coordinate_shape(prepared_sdata, trailing_shape): + table = prepared_sdata.tables["table"] + table.obsm["spatial"] = np.ones((table.n_obs, *trailing_shape)) + with pytest.raises(ValueError, match="must have shape"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + + +def test_nonfinite_coordinates(prepared_sdata): + table = prepared_sdata.tables["table"] + table.obsm["spatial"][0, 0] = np.nan + with pytest.raises(ValueError, match="only finite coordinates"): + gr.spatial_neighbors_knn( + prepared_sdata, + table_key="table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + assert not table.obsp + + +def test_multiple_regions_preserve_row_order_and_separate_graphs(prepared_sdata, mocker): + sdata = prepared_sdata + sdata.shapes["other"] = sdata.shapes["circles"].copy() + table = concat({"circles": sdata.tables["table"], "other": sdata.tables["table"]}, label="region", index_unique="-") + sdata.tables["table"] = TableModel.parse( + table, region=["circles", "other"], region_key="region", instance_key="instance_id" + ) + before_obs, before_coords = table.obs.copy(), table.obsm["spatial"].copy() + params = { + "table_key": "table", + "elements_to_coordinate_systems": {"circles": "global", "other": "global"}, + "n_neighs": 2, + } + expected = gr.spatial_neighbors_knn(sdata, copy=True, **params) + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Must reuse table coordinates")) + result = gr.spatial_neighbors_knn(sdata, coordinate_source="table", copy=True, n_jobs=2, **params) + centroid.assert_not_called() + assert_graph_equal(result, expected) + split = table.n_obs // 2 + assert result.connectivities[:split, split:].nnz == 0 + assert result.connectivities[split:, :split].nnz == 0 + pd.testing.assert_frame_equal(table.obs, before_obs) + np.testing.assert_array_equal(table.obsm["spatial"], before_coords) + + +def test_reordered_observations_and_custom_table_key(prepared_sdata, mocker): + table = prepared_sdata.tables.pop("table")[::-1].copy() + prepared_sdata.tables["cells_table"] = table + expected = gr.spatial_neighbors_knn(table, n_neighs=2, copy=True) + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Must reuse table coordinates")) + actual = gr.spatial_neighbors_knn( + prepared_sdata, + n_neighs=2, + table_key="cells_table", + elements_to_coordinate_systems={"circles": "global"}, + coordinate_source="table", + copy=True, + ) + centroid.assert_not_called() + assert_graph_equal(actual, expected) + + +@pytest.mark.parametrize("problem", ["unknown_instance", "region_order"]) +def test_association_checks_remain_required(prepared_sdata, problem): + sdata = prepared_sdata + table = sdata.tables["table"] + mapping = {"circles": "global"} + if problem == "unknown_instance": + table.obs.loc[table.obs_names[0], "instance_id"] = 999_999 + else: + sdata.shapes["other"] = sdata.shapes["circles"].copy() + table = concat({"other": table, "circles": table}, label="region", index_unique="-") + sdata.tables["table"] = TableModel.parse( + table, region=["circles", "other"], region_key="region", instance_key="instance_id" + ) + mapping = {"circles": "global", "other": "global"} + with pytest.raises(ValueError, match="ordered"): + gr.spatial_neighbors_knn( + sdata, table_key="table", elements_to_coordinate_systems=mapping, coordinate_source="table", copy=True + ) + + +def test_labels_keep_background_and_instance_checks(mocker): + labels = Labels2DModel.parse(np.array([[0, 1, 1], [2, 2, 3], [0, 3, 3]], dtype=np.int32), dims=("y", "x")) + table = TableModel.parse( + AnnData(obs=pd.DataFrame({"region": pd.Categorical(["cells"] * 3), "instance_id": [1, 2, 3]})), + region="cells", + region_key="region", + instance_key="instance_id", + ) + sdata = SpatialData(labels={"cells": labels}, tables={"table": table}) + params = {"table_key": "table", "elements_to_coordinate_systems": {"cells": "global"}, "n_neighs": 1} + gr.spatial_neighbors_knn(sdata, **params) + expected = gr.spatial_neighbors_knn(sdata, copy=True, **params) + centroid = mocker.patch.object(_build, "get_centroids", side_effect=AssertionError("Must reuse coordinates")) + actual = gr.spatial_neighbors_knn(sdata, coordinate_source="table", copy=True, **params) + centroid.assert_not_called() + assert_graph_equal(actual, expected) + table.obs["instance_id"] = [0, 1, 2] + with pytest.raises(ValueError, match="ordered"): + gr.spatial_neighbors_knn(sdata, coordinate_source="table", copy=True, **params) + + +@pytest.mark.parametrize("fail", [True, False]) +def test_backed_table_does_not_load_expression(prepared_sdata, tmp_path, fail): + original = prepared_sdata.tables["table"] + path = tmp_path / "table.h5ad" + original.write_h5ad(path) + table = read_h5ad(path, backed="r") + prepared_sdata.tables["table"] = table + params = { + "table_key": "table", + "elements_to_coordinate_systems": {"circles": "global"}, + "coordinate_source": "table", + "copy": True, + "n_neighs": table.n_obs + 1 if fail else 2, + } + # A graph needs observations and coordinates only; any access to X is a regression. + with patch.object( + AnnData, "X", new_callable=PropertyMock, side_effect=AssertionError("Expression must not be loaded") + ): + try: + if fail: + with pytest.raises(ValueError, match="n_neighbors"): + gr.spatial_neighbors_knn(prepared_sdata, **params) + else: + actual = gr.spatial_neighbors_knn(prepared_sdata, **params) + expected = gr.spatial_neighbors_knn(original, n_neighs=2, library_key="region", copy=True) + assert_graph_equal(actual, expected) + assert table.isbacked + np.testing.assert_array_equal(table.obsm["spatial"], original.obsm["spatial"]) + pd.testing.assert_frame_equal(table.obs, original.obs) + assert table.uns == original.uns + assert not table.obsp + finally: + table.file.close() + on_disk = read_h5ad(path) + np.testing.assert_array_equal(on_disk.X, original.X) + np.testing.assert_array_equal(on_disk.obsm["spatial"], original.obsm["spatial"]) + assert not on_disk.obsp