Repository: scipp/plopp

Issue:
Exception when plotting data with string coordinate
```python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In [4], line 1
----> 1 table.plot()

File ~/code/scipp/plopp/src/plopp/functions/plot.py:94, in plot(obj, aspect, cbar, crop, errorbars, grid, ignore_size, mask_color, norm, scale, title, vmin, vmax, **kwargs)
     92     return figure(*nodes, **all_args)
     93 else:
---> 94     return figure(input_node(preprocess(obj, crop=crop, ignore_size=ignore_size)),
     95                   **all_args)

File ~/code/scipp/plopp/src/plopp/functions/figure.py:16, in figure(*args, **kwargs)
     14 else:
     15     from ..graphics import StaticFig
---> 16     return StaticFig(*args, **kwargs)

File ~/code/scipp/plopp/src/plopp/graphics/fig.py:65, in Figure.__init__(self, ax, figsize, title, grid, vmin, vmax, norm, aspect, scale, cbar, errorbars, mask_color, crop, *nodes, **kwargs)
     62 self._new_artist = False
     64 self._post_init()
---> 65 self.render()

File ~/code/scipp/plopp/src/plopp/graphics/fig.py:222, in Figure.render(self)
    220     new_values = node.request_data()
    221     self.update(new_values=new_values, key=node.id, draw=False)
--> 222 self._autoscale()
    223 self.crop(**self._crop)
    224 self.draw()

File ~/code/scipp/plopp/src/plopp/graphics/fig.py:87, in Figure._autoscale(self)
     85 yscale = self._ax.get_yscale()
     86 for child in self._children.values():
---> 87     xlims, ylims = child.get_limits(xscale=xscale, yscale=yscale)
     88     if isinstance(child, Line):
     89         if self._user_vmin is not None:

File ~/code/scipp/plopp/src/plopp/graphics/line.py:182, in Line.get_limits(self, xscale, yscale)
    178 def get_limits(self, xscale, yscale) -> Tuple[float, ...]:
    179     """
    180     """
    181     xmin, xmax = fix_empty_range(
--> 182         find_limits(self._data.meta[self._dim], scale=xscale))
    183     ymin, ymax = fix_empty_range(find_limits(self._data.data, scale=yscale))
    185     for lim in (xmin, xmax, ymin, ymax):

File ~/code/scipp/plopp/src/plopp/core/limits.py:14, in find_limits(x, scale)
     10 """
     11 Find sensible limits, depending on linear or log scale.
     12 """
     13 v = x.values
---> 14 finite_vals = v[np.isfinite(v)]
     15 if scale == "log":
     16     finite_min = np.amin(finite_vals, initial=np.inf, where=finite_vals > 0)

TypeError: ufunc 'isfinite' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting rule ''safe''
```

Discussion:

Patch:
diff --git a/src/plopp/core/utils.py b/src/plopp/core/utils.py
index 7038ea2..6bcc163 100644
--- a/src/plopp/core/utils.py
+++ b/src/plopp/core/utils.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2022 Scipp contributors (https://github.com/scipp)
 
-from scipp import scalar, concat, midpoints
+import scipp as sc
 import uuid
 from functools import reduce
 
@@ -14,19 +14,20 @@ def coord_as_bin_edges(da, key, dim=None):
     if dim is None:
         dim = key
     x = da.meta[key]
+    if x.dtype == str:
+        x = sc.arange(dim, float(x.shape[0]), unit=x.unit)
     if da.meta.is_edges(key, dim=dim):
         return x
-    idim = x.dims.index(dim)
-    if x.shape[idim] < 2:
-        half = scalar(0.5, unit=x.unit)
-        return concat([x[dim, 0:1] - half, x[dim, 0:1] + half], dim)
+    if x.sizes[dim] < 2:
+        half = sc.scalar(0.5, unit=x.unit)
+        return sc.concat([x[dim, 0:1] - half, x[dim, 0:1] + half], dim)
     else:
-        center = midpoints(x, dim=dim)
+        center = sc.midpoints(x, dim=dim)
         # Note: use range of 0:1 to keep dimension dim in the slice to avoid
         # switching round dimension order in concatenate step.
         left = center[dim, 0:1] - (x[dim, 1] - x[dim, 0])
         right = center[dim, -1] + (x[dim, -1] - x[dim, -2])
-        return concat([left, center, right], dim)
+        return sc.concat([left, center, right], dim)
 
 
 def repeat(x, dim, n):
@@ -48,7 +49,7 @@ def number_to_variable(x):
     """
     Convert the input int or float to a variable.
     """
-    return scalar(x, unit=None) if isinstance(x, (int, float)) else x
+    return sc.scalar(x, unit=None) if isinstance(x, (int, float)) else x
 
 
 def name_with_unit(var=None, name=None, log=False):
diff --git a/src/plopp/graphics/canvas.py b/src/plopp/graphics/canvas.py
index d3b4e4d..993c3b8 100644
--- a/src/plopp/graphics/canvas.py
+++ b/src/plopp/graphics/canvas.py
@@ -6,6 +6,7 @@ from ..core.utils import number_to_variable
 from .utils import fig_to_bytes, silent_mpl_figure
 
 import matplotlib.pyplot as plt
+from matplotlib.collections import QuadMesh
 import numpy as np
 import scipp as sc
 from typing import Any, Tuple
@@ -92,17 +93,18 @@ class Canvas:
             self._ymin = min(self._ymin, ymin)
             self._ymax = max(self._ymax, ymax)
         for c in self.ax.collections:
-            coords = c.get_coordinates()
-            left, right = fix_empty_range(
-                find_limits(sc.array(dims=['x', 'y'], values=coords[..., 0]),
-                            scale=self.xscale))
-            bottom, top = fix_empty_range(
-                find_limits(sc.array(dims=['x', 'y'], values=coords[..., 1]),
-                            scale=self.yscale))
-            self._xmin = min(self._xmin, left.value)
-            self._xmax = max(self._xmax, right.value)
-            self._ymin = min(self._ymin, bottom.value)
-            self._ymax = max(self._ymax, top.value)
+            if isinstance(c, QuadMesh):
+                coords = c.get_coordinates()
+                left, right = fix_empty_range(
+                    find_limits(sc.array(dims=['x', 'y'], values=coords[..., 0]),
+                                scale=self.xscale))
+                bottom, top = fix_empty_range(
+                    find_limits(sc.array(dims=['x', 'y'], values=coords[..., 1]),
+                                scale=self.yscale))
+                self._xmin = min(self._xmin, left.value)
+                self._xmax = max(self._xmax, right.value)
+                self._ymin = min(self._ymin, bottom.value)
+                self._ymax = max(self._ymax, top.value)
         self.ax.set_xlim(_none_if_not_finite(self._xmin),
                          _none_if_not_finite(self._xmax))
         self.ax.set_ylim(_none_if_not_finite(self._ymin),
diff --git a/src/plopp/graphics/line.py b/src/plopp/graphics/line.py
index 297a1ef..2a0784e 100644
--- a/src/plopp/graphics/line.py
+++ b/src/plopp/graphics/line.py
@@ -125,9 +125,8 @@ class Line:
             if data["mask"] is not None:
                 data["mask"]["y"] = np.concatenate(
                     (data["mask"]["y"][0:1], data["mask"]["y"]))
-            data["variances"]["x"] = 0.5 * (x[1:] + x[:-1])
-        else:
-            data["variances"]["x"] = x
+        if self._data.variances is not None:
+            data["variances"]["x"] = 0.5 * (x[1:] + x[:-1]) if hist else x
         data["variances"]["y"] = y
         data["hist"] = hist
         return data
diff --git a/src/plopp/graphics/mesh.py b/src/plopp/graphics/mesh.py
index 8f32e03..b96802d 100644
--- a/src/plopp/graphics/mesh.py
+++ b/src/plopp/graphics/mesh.py
@@ -23,33 +23,29 @@ def _get_dims_of_1d_and_2d_coords(coords):
     return dim_1d, dim_2d
 
 
-def _maybe_repeat_values(data, coords):
-    dim_1d, dim_2d = _get_dims_of_1d_and_2d_coords(coords)
+def _maybe_repeat_values(data, dim_1d, dim_2d):
     if dim_2d is None:
         return data
     return repeat(data, dim=dim_1d[1], n=2)[dim_1d[1], :-1]
 
 
-def _from_data_array_to_pcolormesh(data, coords):
-    z = _maybe_repeat_values(data=data, coords=coords)
-    dim_1d, dim_2d = _get_dims_of_1d_and_2d_coords(coords)
+def _from_data_array_to_pcolormesh(data, coords, dim_1d, dim_2d):
+    z = _maybe_repeat_values(data=data, dim_1d=dim_1d, dim_2d=dim_2d)
     if dim_2d is None:
-        return coords['x']['var'], coords['y']['var'], z
+        return coords['x'], coords['y'], z
 
     # Broadcast 1d coord to 2d and repeat along 1d dim
     # TODO: It may be more efficient here to first repeat and then broadcast, but
     # the current order is simpler in implementation.
-    broadcasted_coord = repeat(sc.broadcast(coords[dim_1d[0]]['var'],
+    broadcasted_coord = repeat(sc.broadcast(coords[dim_1d[0]],
                                             sizes={
-                                                **coords[dim_2d[0]]['var'].sizes,
-                                                **coords[dim_1d[0]]['var'].sizes
+                                                **coords[dim_2d[0]].sizes,
+                                                **coords[dim_1d[0]].sizes
                                             }).transpose(data.dims),
                                dim=dim_1d[1],
                                n=2)
     # Repeat 2d coord along 1d dim
-    repeated_coord = repeat(coords[dim_2d[0]]['var'].transpose(data.dims),
-                            dim=dim_1d[1],
-                            n=2)
+    repeated_coord = repeat(coords[dim_2d[0]].transpose(data.dims), dim=dim_1d[1], n=2)
     out = {dim_1d[0]: broadcasted_coord[dim_1d[1], 1:-1], dim_2d[0]: repeated_coord}
     return out['x'], out['y'], z
 
@@ -75,15 +71,20 @@ class Mesh:
 
         self._ax = canvas.ax
         self._data = data
-        self._dims = {'x': self._data.dims[1], 'y': self._data.dims[0]}
-        self._bin_edge_coords = {
-            k: {
-                'dim': self._dims[k],
-                'var': coord_as_bin_edges(self._data, self._dims[k])
+
+        to_dim_search = {}
+        string_labels = {}
+        bin_edge_coords = {}
+        for i, k in enumerate('yx'):
+            to_dim_search[k] = {
+                'dim': self._data.dims[i],
+                'var': self._data.meta[self._data.dims[i]]
             }
-            for k in 'xy'
-        }
+            bin_edge_coords[k] = coord_as_bin_edges(self._data, self._data.dims[i])
+            if self._data.meta[self._data.dims[i]].dtype == str:
+                string_labels[k] = self._data.meta[self._data.dims[i]]
 
+        self._dim_1d, self._dim_2d = _get_dims_of_1d_and_2d_coords(to_dim_search)
         self._xlabel = None
         self._ylabel = None
         self._title = None
@@ -100,7 +101,9 @@ class Mesh:
             self._extend = 'max'
 
         x, y, z = _from_data_array_to_pcolormesh(data=self._data.data,
-                                                 coords=self._bin_edge_coords)
+                                                 coords=bin_edge_coords,
+                                                 dim_1d=self._dim_1d,
+                                                 dim_2d=self._dim_2d)
         self._mesh = self._ax.pcolormesh(x.values,
                                          y.values,
                                          z.values,
@@ -109,16 +112,21 @@ class Mesh:
                                          **kwargs)
         self._mesh.set_array(None)
 
+        for xy, var in string_labels.items():
+            getattr(self._ax, f'set_{xy}ticks')(np.arange(float(var.shape[0])))
+            getattr(self._ax, f'set_{xy}ticklabels')(var.values)
+
     @property
     def data(self):
-        out = sc.DataArray(data=_maybe_repeat_values(data=self._data.data,
-                                                     coords=self._bin_edge_coords))
+        out = sc.DataArray(data=_maybe_repeat_values(
+            data=self._data.data, dim_1d=self._dim_1d, dim_2d=self._dim_2d))
         if self._data.masks:
             out.masks['one_mask'] = _maybe_repeat_values(data=sc.broadcast(
                 merge_masks(self._data.masks),
                 dims=self._data.dims,
                 shape=self._data.shape),
-                                                         coords=self._bin_edge_coords)
+                                                         dim_1d=self._dim_1d,
+                                                         dim_2d=self._dim_2d)
         return out
 
     def set_colors(self, rgba):


Test patch:
diff --git a/tests/graphics/fig1d_test.py b/tests/graphics/fig1d_test.py
index 4bf2e2f..4074dc6 100644
--- a/tests/graphics/fig1d_test.py
+++ b/tests/graphics/fig1d_test.py
@@ -5,6 +5,7 @@ from plopp.data import dense_data_array
 from plopp.graphics.fig1d import Figure1d
 from plopp.graphics.line import Line
 from plopp import input_node
+import numpy as np
 import scipp as sc
 import pytest
 
@@ -38,7 +39,31 @@ def test_create_with_node():
     da = dense_data_array(ndim=1)
     fig = Figure1d(input_node(da))
     assert len(fig.artists) == 1
-    assert sc.identical(list(fig.artists.values())[0]._data, da)
+    line = list(fig.artists.values())[0]
+    assert sc.identical(line._data, da)
+    assert line._error is None
+
+
+def test_with_errorbars():
+    da = dense_data_array(ndim=1, with_variance=True)
+    fig = Figure1d(input_node(da))
+    assert len(fig.artists) == 1
+    line = list(fig.artists.values())[0]
+    assert line._error is not None
+    fig = Figure1d(input_node(da), errorbars=False)
+    line = list(fig.artists.values())[0]
+    assert line._error is None
+
+
+def test_with_binedges():
+    da = dense_data_array(ndim=1, binedges=True)
+    fig = Figure1d(input_node(da))
+    assert len(fig.artists) == 1
+    line = list(fig.artists.values())[0]
+    assert sc.identical(line._data, da)
+    xdata = line._line.get_xdata()
+    assert np.allclose(xdata, da.coords['xx'].values)
+    assert len(xdata) == da.sizes['xx'] + 1
 
 
 def test_log_norm():
@@ -81,3 +106,19 @@ def test_update_does_not_shrink_limits():
     new_lims = fig.canvas.ax.get_ylim()
     assert new_lims[0] == old_lims[0]
     assert new_lims[1] == old_lims[1]
+
+
+def test_with_string_coord():
+    strings = ['a', 'b', 'c', 'd', 'e']
+    da = sc.DataArray(data=sc.arange('x', 5.),
+                      coords={'x': sc.array(dims=['x'], values=strings, unit='m')})
+    fig = Figure1d(input_node(da))
+    assert [t.get_text() for t in fig.canvas.ax.get_xticklabels()] == strings
+
+
+def test_with_strings_as_bin_edges():
+    strings = ['a', 'b', 'c', 'd', 'e', 'f']
+    da = sc.DataArray(data=sc.arange('x', 5.),
+                      coords={'x': sc.array(dims=['x'], values=strings, unit='m')})
+    fig = Figure1d(input_node(da))
+    assert [t.get_text() for t in fig.canvas.ax.get_xticklabels()] == strings
diff --git a/tests/graphics/fig2d_test.py b/tests/graphics/fig2d_test.py
index 2ac31af..2dc9765 100644
--- a/tests/graphics/fig2d_test.py
+++ b/tests/graphics/fig2d_test.py
@@ -42,6 +42,21 @@ def test_create_with_node():
     assert sc.identical(list(fig.artists.values())[0]._data, da)
 
 
+def test_create_with_bin_edges():
+    da = dense_data_array(ndim=2, binedges=True)
+    fig = Figure2d(input_node(da))
+    assert len(fig.artists) == 1
+    assert sc.identical(list(fig.artists.values())[0]._data, da)
+
+
+def test_create_with_only_one_bin_edge_coord():
+    da = dense_data_array(ndim=2, binedges=True)
+    da.coords['xx'] = sc.midpoints(da.coords['xx'])
+    fig = Figure2d(input_node(da))
+    assert len(fig.artists) == 1
+    assert sc.identical(list(fig.artists.values())[0]._data, da)
+
+
 def test_log_norm():
     fig = Figure2d(norm='log')
     assert fig.colormapper.norm == 'log'
@@ -92,3 +107,36 @@ def test_update_on_one_mesh_changes_colors_on_second_mesh():
     a.func = lambda: da1 * 5.0
     a.notify_children('updated a')
     assert not np.allclose(old_b_colors, f.artists[b.id]._mesh.get_facecolors())
+
+
+def test_with_string_coord():
+    strings = ['a', 'b', 'c', 'd', 'e']
+    da = sc.DataArray(data=sc.array(dims=['y', 'x'], values=np.random.random((5, 5))),
+                      coords={
+                          'x': sc.array(dims=['x'], values=strings, unit='s'),
+                          'y': sc.arange('y', 5., unit='m')
+                      })
+    fig = Figure2d(input_node(da))
+    assert [t.get_text() for t in fig.canvas.ax.get_xticklabels()] == strings
+
+
+def test_with_strings_as_bin_edges():
+    strings = ['a', 'b', 'c', 'd', 'e', 'f']
+    da = sc.DataArray(data=sc.array(dims=['y', 'x'], values=np.random.random((5, 5))),
+                      coords={
+                          'x': sc.array(dims=['x'], values=strings, unit='s'),
+                          'y': sc.arange('y', 6., unit='m')
+                      })
+    fig = Figure2d(input_node(da))
+    assert [t.get_text() for t in fig.canvas.ax.get_xticklabels()] == strings
+
+
+def test_with_strings_as_bin_edges_other_coord_is_bin_centers():
+    strings = ['a', 'b', 'c', 'd', 'e', 'f']
+    da = sc.DataArray(data=sc.array(dims=['y', 'x'], values=np.random.random((5, 5))),
+                      coords={
+                          'x': sc.array(dims=['x'], values=strings, unit='s'),
+                          'y': sc.arange('y', 5., unit='m')
+                      })
+    fig = Figure2d(input_node(da))
+    assert [t.get_text() for t in fig.canvas.ax.get_xticklabels()] == strings
diff --git a/tests/graphics/mesh_test.py b/tests/graphics/mesh_test.py
index 899dc8b..4329360 100644
--- a/tests/graphics/mesh_test.py
+++ b/tests/graphics/mesh_test.py
@@ -10,7 +10,6 @@ import scipp as sc
 def test_mesh_creation():
     da = dense_data_array(ndim=2)
     mesh = Mesh(canvas=Canvas(), data=da)
-    assert mesh._dims == {'x': 'xx', 'y': 'yy'}
     assert sc.identical(mesh._data, da)
 
 
@@ -29,7 +28,8 @@ def test_mesh_creation_masks():
 def test_mesh_creation_ragged_coord():
     da = dense_data_array(ndim=2, ragged=True)
     mesh = Mesh(canvas=Canvas(), data=da)
-    assert mesh._dims == {'x': 'xx', 'y': 'yy'}
+    assert mesh._dim_2d == ('x', 'xx')
+    assert mesh._dim_1d == ('y', 'yy')
     assert sc.identical(mesh._data, da)