Repository: scipp/plopp

Issue:
Cannot use plopp.scatter3d with matplotlib subplots
Trying to use `plopp.scatter3d` with matplotlib subplots gives an error: "unexpected keyword argument 'ax'".

Here is a code example to reproduce the error

```
time = np.linspace(0, 10, 50)
x = np.cos(time)
y = np.sin(time)

da = sc.DataArray(
    data=sc.array(dims=['row'], values=time),
    coords={
        'x': sc.array(dims=['row'], unit='m', values=x),
        'y': sc.array(dims=['row'], unit='m', values=y),
        'time': sc.array(dims=['row'], unit='s', values=time)
    })

fig, ax = plt.subplots(1, 2, figsize=(10, 10))

p1 = pp.plot(da, ax=ax[0])
p2 = pp.scatter3d(da, x='x', y='y', z='time', pixel_size=0.2, ax=ax[1])

fig.tight_layout()
```

Discussion:

Patch:
diff --git a/docs/customization/subplots.ipynb b/docs/customization/subplots.ipynb
index d8b6a57..df56dd0 100644
--- a/docs/customization/subplots.ipynb
+++ b/docs/customization/subplots.ipynb
@@ -158,6 +158,17 @@
     "\n",
     "</div>"
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1719b19e-ca7b-4da5-a895-6d2140f7d5b3",
+   "metadata": {},
+   "source": [
+    "## Subplots with 3D scatter plots\n",
+    "\n",
+    "Subplots with three-dimensional visualizations inside Matplotlib axes are not supported.\n",
+    "See the [FAQ page](../faq) for more details."
+   ]
   }
  ],
  "metadata": {
@@ -176,7 +187,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.13"
+   "version": "3.9.15"
   }
  },
  "nbformat": 4,
diff --git a/docs/faq.ipynb b/docs/faq.ipynb
new file mode 100644
index 0000000..d8003d1
--- /dev/null
+++ b/docs/faq.ipynb
@@ -0,0 +1,60 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "1cada456-26a6-486f-addc-9ee9bdc119e1",
+   "metadata": {},
+   "source": [
+    "# Frequently Asked Questions\n",
+    "\n",
+    "## Subplots with 3D scatter plots\n",
+    "\n",
+    "The three-dimensional visualizations in Plopp do not use Matplotlib, but Pythreejs.\n",
+    "Consequently, it is not possible to insert a [three-dimensional scatter plot](user-guide/scatter3d-plot) inside a subplot made of Matplotlib axes.\n",
+    "The only way to make a three-dimensional view part of subplots is to use the container widgets described in the [subplots page](customization/subplots)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5307e510-77b8-48c2-87f4-891de0969c87",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "import plopp as pp\n",
+    "import matplotlib.pyplot as plt\n",
+    "from plopp.widgets import Box\n",
+    "\n",
+    "da1 = pp.data.data1d()\n",
+    "da2 = pp.data.scatter()\n",
+    "\n",
+    "p1 = pp.plot(da1)\n",
+    "p2 = pp.scatter3d(da2, x='x', y='y', z='z')\n",
+    "\n",
+    "Box([p1, p2])"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.15"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/index.rst b/docs/index.rst
index 9c71f19..db9a341 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -86,8 +86,10 @@ Get in touch
    examples/gallery
 
 .. toctree::
-   :caption: Reference
+   :caption: About
    :maxdepth: 3
    :hidden:
 
    reference
+   faq
+   Release notes <https://github.com/scipp/plopp/releases>
diff --git a/docs/version.py b/docs/version.py
index 18af7ed..fe04274 100644
--- a/docs/version.py
+++ b/docs/version.py
@@ -10,8 +10,14 @@ import argparse
 
 def _get_releases(repo: str, organization: str = 'scipp') -> List[Version]:
     """Return reversed sorted list of release tag names."""
-    r = requests.get(f'https://api.github.com/repos/{organization}/{repo}/releases')
-    if r.status_code != 200:
+    max_tries = 3
+    ok = False
+    for n in range(max_tries):
+        r = requests.get(f'https://api.github.com/repos/{organization}/{repo}/releases')
+        ok = r.status_code == 200
+        if ok:
+            break
+    if not ok:
         return []
     data = r.json()
     return sorted([parse(e['tag_name']) for e in data if not e['draft']], reverse=True)
diff --git a/src/plopp/data/__init__.py b/src/plopp/data/__init__.py
index 4d0ef02..92ad38f 100644
--- a/src/plopp/data/__init__.py
+++ b/src/plopp/data/__init__.py
@@ -3,7 +3,6 @@
 
 # flake8: noqa F401
 
-from .factory import (variable, data_array, dataset, scatter_data, data1d, data2d,
-                      data3d, histogram1d, histogram2d, histogram3d, dataset1d,
-                      dataset2d)
+from .factory import (variable, data_array, dataset, scatter, data1d, data2d, data3d,
+                      histogram1d, histogram2d, histogram3d, dataset1d, dataset2d)
 from . import testing
diff --git a/src/plopp/data/factory.py b/src/plopp/data/factory.py
index 085cf38..9ae5046 100644
--- a/src/plopp/data/factory.py
+++ b/src/plopp/data/factory.py
@@ -156,7 +156,7 @@ def dataset(entries: List[str] = None, **kwargs) -> sc.Dataset:
     return ds
 
 
-def scatter_data(npoints=500, scale=10.0, seed=1) -> sc.DataArray:
+def scatter(npoints=500, scale=10.0, seed=1) -> sc.DataArray:
     """
     Generate some three-dimensional scatter data, based on a normal distribution.
 
diff --git a/src/plopp/functions/common.py b/src/plopp/functions/common.py
index d0dee26..3ad39f0 100644
--- a/src/plopp/functions/common.py
+++ b/src/plopp/functions/common.py
@@ -4,7 +4,7 @@
 from ..core.utils import number_to_variable
 
 from matplotlib import get_backend
-from numpy import ndarray, prod
+import numpy as np
 import scipp as sc
 from typing import Dict, List, Union, Optional
 import warnings
@@ -27,13 +27,13 @@ def require_interactive_backend(func: str):
                            "notebook.")
 
 
-def _to_data_array(obj: Union[ndarray, sc.Variable, sc.DataArray]):
+def _to_data_array(obj: Union[np.ndarray, sc.Variable, sc.DataArray]):
     """
     Convert an input to a DataArray, potentially adding fake coordinates if they are
     missing.
     """
     out = obj
-    if isinstance(out, ndarray):
+    if isinstance(out, np.ndarray):
         dims = [f"axis-{i}" for i in range(len(out.shape))]
         out = sc.Variable(dims=dims, values=out)
     if isinstance(out, sc.Variable):
@@ -61,7 +61,7 @@ def _check_size(da: sc.DataArray):
     limits = {1: 1_000_000, 2: 2500 * 2500}
     if da.ndim not in limits:
         raise ValueError("plot can only handle 1d and 2d data.")
-    if prod(da.shape) > limits[da.ndim]:
+    if np.prod(da.shape) > limits[da.ndim]:
         raise ValueError(f"Plotting data of size {da.shape} may take very long or use "
                          "an excessive amount of memory. This is therefore disabled by "
                          "default. To bypass this check, use `ignore_size=True`.")
@@ -81,7 +81,7 @@ def _all_dims_sorted(var, order='ascending'):
     return all([sc.allsorted(var, dim, order=order) for dim in var.dims])
 
 
-def preprocess(obj: Union[ndarray, sc.Variable, sc.DataArray],
+def preprocess(obj: Union[np.ndarray, sc.Variable, sc.DataArray],
                crop: Optional[Dict[str, Dict[str, sc.Variable]]] = None,
                name: str = '',
                ignore_size: bool = False,
@@ -136,11 +136,15 @@ def preprocess(obj: Union[ndarray, sc.Variable, sc.DataArray],
         out = out.rename_dims(**renamed_dims)
     for name, coord in out.coords.items():
         if coord.ndim > 0:
-            if not (_all_dims_sorted(coord, order='ascending')
-                    or _all_dims_sorted(coord, order='descending')):
-                warnings.warn(
-                    'The input contains a coordinate with unsorted values. '
-                    'The results may be unpredictable. Coordinates can be sorted using '
-                    '`scipp.sort(data, dim="dim_to_be_sorted", order="ascending")`.',
-                    UserWarning)
+            try:
+                if not (_all_dims_sorted(coord, order='ascending')
+                        or _all_dims_sorted(coord, order='descending')):
+                    warnings.warn(
+                        'The input contains a coordinate with unsorted values. '
+                        'The results may be unpredictable. '
+                        'Coordinates can be sorted using '
+                        '`scipp.sort(data, dim="to_be_sorted", order="ascending")`.',
+                        UserWarning)
+            except sc.DTypeError:
+                pass
     return out
diff --git a/src/plopp/functions/scatter3d.py b/src/plopp/functions/scatter3d.py
index 81366d8..d438dd9 100644
--- a/src/plopp/functions/scatter3d.py
+++ b/src/plopp/functions/scatter3d.py
@@ -66,6 +66,13 @@ def scatter3d(da: sc.DataArray,
     from ..functions import figure3d
     from ..widgets import Box, ToggleTool, TriCutTool
 
+    if 'ax' in kwargs:
+        raise ValueError(
+            'Keyword "ax" detected. Embedding 3D scatter plots inside Matplotlib axes '
+            'is not supported. See '
+            'https://scipp.github.io/plopp/customization/subplots.html#FAQ:-subplots-with-3D-scatter-plots'  # noqa: E501
+        )
+
     check_not_binned(da)
 
     if pos is not None:


Test patch:
diff --git a/src/plopp/data/testing.py b/src/plopp/data/testing.py
index 2ac58c9..bed0b43 100644
--- a/src/plopp/data/testing.py
+++ b/src/plopp/data/testing.py
@@ -9,4 +9,4 @@ testing_dim_list = ['xx', 'yy', 'zz', 'time', 'temperature']
 variable = partial(fa.variable, dim_list=testing_dim_list)
 data_array = partial(fa.data_array, dim_list=testing_dim_list)
 dataset = partial(fa.dataset, dim_list=testing_dim_list)
-scatter_data = fa.scatter_data
+scatter = fa.scatter
diff --git a/tests/functions/common_test.py b/tests/functions/common_test.py
index f2fc765..3b378fe 100644
--- a/tests/functions/common_test.py
+++ b/tests/functions/common_test.py
@@ -1,10 +1,10 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
-import plopp as pp
 from plopp.data.testing import data_array
 from plopp.functions.common import preprocess
 import pytest
+import numpy as np
 import scipp as sc
 
 
@@ -25,8 +25,20 @@ def test_preprocess_use_non_dimension_coords():
 
 
 def test_preprocess_warns_when_coordinate_is_not_sorted():
-    da = pp.data.data_array(ndim=1)
-    unsorted = sc.concat([da['x', 20:], da['x', :20]], dim='x')
+    da = data_array(ndim=1)
+    unsorted = sc.concat([da['xx', 20:], da['xx', :20]], dim='xx')
     with pytest.warns(UserWarning,
                       match='The input contains a coordinate with unsorted values'):
         preprocess(unsorted)
+
+
+def test_preprocess_no_warning_if_dtype_cannot_be_sorted():
+    da = data_array(ndim=1)
+    da.coords['vecs'] = sc.vectors(dims=['xx'],
+                                   values=np.random.random((da.sizes['xx'], 3)))
+    out = preprocess(da)  # no warning should be emitted
+    assert 'vecs' in out.coords
+    da.coords['strings'] = sc.array(dims=['xx'],
+                                    values=list('ba' * (da.sizes['xx'] // 2)))
+    out = preprocess(da)  # no warning should be emitted
+    assert 'strings' in out.coords
diff --git a/tests/functions/scatter3d_test.py b/tests/functions/scatter3d_test.py
index 3dafc5d..ed59660 100644
--- a/tests/functions/scatter3d_test.py
+++ b/tests/functions/scatter3d_test.py
@@ -3,23 +3,23 @@
 
 import numpy as np
 import plopp as pp
-from plopp.data.testing import scatter_data
+from plopp.data.testing import scatter
 import pytest
 import scipp as sc
 
 
 def test_scatter3d_from_pos():
-    da = scatter_data()
+    da = scatter()
     pp.scatter3d(da, pos='position')
 
 
 def test_scatter3d_from_xyz():
-    da = scatter_data()
+    da = scatter()
     pp.scatter3d(da, x='x', y='y', z='z')
 
 
 def test_scatter3d_raises_with_both_pos_and_xyz():
-    da = scatter_data()
+    da = scatter()
     with pytest.raises(ValueError, match=r'If pos \(position\) is defined, all of'):
         pp.scatter3d(da, pos='position', x='x', y='y', z='z')
 
@@ -49,3 +49,9 @@ def test_raises_ValueError_when_given_binned_data():
     da.coords['position'] = sc.geometry.position(x, x, x)
     with pytest.raises(ValueError, match='Cannot plot binned data'):
         pp.scatter3d(da, pos='position')
+
+
+def test_raises_ValueError_when_given_ax_kwarg():
+    da = scatter()
+    with pytest.raises(ValueError, match='Keyword "ax" detected'):
+        pp.scatter3d(da, x='x', y='y', z='z', ax='dummy')
diff --git a/tests/graphics/fig3d_test.py b/tests/graphics/fig3d_test.py
index 7b26a00..5994498 100644
--- a/tests/graphics/fig3d_test.py
+++ b/tests/graphics/fig3d_test.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
-from plopp.data.testing import scatter_data
+from plopp.data.testing import scatter
 from plopp.graphics.fig3d import Figure3d
 from plopp.graphics.point_cloud import PointCloud
 from plopp import input_node
@@ -9,7 +9,7 @@ import scipp as sc
 
 
 def test_creation():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z')
     assert len(fig.artists) == 1
     key = list(fig.artists.keys())[0]
@@ -18,7 +18,7 @@ def test_creation():
 
 
 def test_update():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z')
     assert len(fig.artists) == 1
     key = list(fig.artists.keys())[0]
@@ -27,6 +27,6 @@ def test_update():
 
 
 def test_log_norm():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z', norm='log')
     assert fig.colormapper.norm == 'log'
diff --git a/tests/graphics/interactive_test.py b/tests/graphics/interactive_test.py
index 879b4c6..1015d5d 100644
--- a/tests/graphics/interactive_test.py
+++ b/tests/graphics/interactive_test.py
@@ -2,7 +2,7 @@
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
 import plopp as pp
-from plopp.data.testing import data_array, scatter_data
+from plopp.data.testing import data_array, scatter
 from plopp.graphics.interactive import (InteractiveFig1d, InteractiveFig2d,
                                         InteractiveFig3d)
 
@@ -67,6 +67,6 @@ def test_log_norm_2d_toolbar_button(use_ipympl):
 
 
 def test_log_norm_3d_toolbar_button():
-    da = scatter_data()
+    da = scatter()
     fig = InteractiveFig3d(pp.input_node(da), x='x', y='y', z='z', norm='log')
     assert fig.toolbar['lognorm'].value
diff --git a/tests/graphics/point_cloud_test.py b/tests/graphics/point_cloud_test.py
index a05aeee..cc4e67a 100644
--- a/tests/graphics/point_cloud_test.py
+++ b/tests/graphics/point_cloud_test.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
-from plopp.data.testing import scatter_data
+from plopp.data.testing import scatter
 from plopp.graphics.point_cloud import PointCloud
 
 import numpy as np
@@ -10,7 +10,7 @@ import pytest
 
 
 def test_creation():
-    da = scatter_data()
+    da = scatter()
     cloud = PointCloud(data=da, x='x', y='y', z='z')
     assert sc.identical(cloud._data, da)
     assert np.allclose(cloud.geometry.attributes['position'].array,
@@ -18,7 +18,7 @@ def test_creation():
 
 
 def test_update():
-    da = scatter_data()
+    da = scatter()
     cloud = PointCloud(data=da, x='x', y='y', z='z')
     cloud.update(da)
     assert sc.identical(cloud._data, da)
@@ -27,7 +27,7 @@ def test_update():
 
 
 def test_get_limits():
-    da = scatter_data()
+    da = scatter()
     pix = 0.5
     cloud = PointCloud(data=da, x='x', y='y', z='z', pixel_size=pix)
     xlims, ylims, zlims = cloud.get_limits()
@@ -40,7 +40,7 @@ def test_get_limits():
 
 
 def test_get_limits_flat_panel():
-    da = scatter_data()
+    da = scatter()
     da.coords['z'] *= 0.
     pix = 0.5
     cloud = PointCloud(data=da, x='x', y='y', z='z', pixel_size=pix)
@@ -59,14 +59,14 @@ def test_pixel_size():
     the size, depending on the device pixel ratio. Making a reference with a default
     size of 1 makes it easier to test.
     """
-    da = scatter_data()
+    da = scatter()
     reference = PointCloud(data=da, x='x', y='y', z='z', pixel_size=1)
     cloud = PointCloud(data=da, x='x', y='y', z='z', pixel_size=sc.scalar(2, unit='m'))
     assert cloud.material.size == 2.0 * reference.material.size
 
 
 def test_pixel_size_unit_conversion():
-    da = scatter_data()
+    da = scatter()
     reference = PointCloud(data=da, x='x', y='y', z='z', pixel_size=1)
     cloud = PointCloud(data=da,
                        x='x',
@@ -79,7 +79,7 @@ def test_pixel_size_unit_conversion():
 
 
 def test_pixel_size_cannot_have_units_when_spatial_dimensions_have_different_units():
-    da = scatter_data()
+    da = scatter()
     new_x = da.coords['x'].copy()
     new_x.unit = 's'
     da.coords['x'] = new_x
diff --git a/tests/widgets/cut3d_test.py b/tests/widgets/cut3d_test.py
index a02de39..526dd54 100644
--- a/tests/widgets/cut3d_test.py
+++ b/tests/widgets/cut3d_test.py
@@ -1,14 +1,14 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
-from plopp.data.testing import scatter_data
+from plopp.data.testing import scatter
 from plopp.graphics.fig3d import Figure3d
 from plopp.widgets import TriCutTool
 from plopp import input_node
 
 
 def test_show_hide_cuts():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z')
     tri = TriCutTool(fig)
     assert len(fig.artists) == 1
@@ -27,7 +27,7 @@ def test_show_hide_cuts():
 
 
 def test_move_cut():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z')
     tri = TriCutTool(fig)
     tri.cut_x.button.value = True
@@ -41,7 +41,7 @@ def test_move_cut():
 
 
 def test_cut_thickness():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z')
     tri = TriCutTool(fig)
     tri.cut_x.button.value = True
@@ -59,7 +59,7 @@ def test_cut_thickness():
 
 
 def test_empty_cut():
-    da = scatter_data()
+    da = scatter()
     fig = Figure3d(input_node(da), x='x', y='y', z='z')
     tri = TriCutTool(fig)
     assert len(fig.artists) == 1