Repository: scipp/plopp

Issue:
Cannot make 1d plot for data with datetime coord and variances
Coordinate is datetime bin edges, and data has variances:
```Py
time = sc.array(dims=['time'], values=np.arange(np.datetime64('2017-03-16T20:58:17'),
                                                np.datetime64('2017-03-16T21:15:17'), 10))
v = np.random.rand(time.sizes['time']-1)
da = sc.DataArray(data=sc.array(dims=['time'], values=10 * v, variances=v),
                    coords={'time':time})
pp.plot(da)
```
raises
```
UFuncTypeError: ufunc 'add' cannot use operands with types dtype('<M8[s]') and dtype('<M8[s]')
```

This is because [here](https://github.com/scipp/plopp/blob/main/src/plopp/backends/matplotlib/line.py#L178)  we compute the centre of the bins to place the error bars in the middle of the bins. But the operation fails when dtype is datetime.

The code above works if the coord is not bin-edges.

**Note:** this needs to be fixed in both matplotlib and plotly backends.

Discussion:

Patch:
diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py
index 982f9df..ad2a9d4 100644
--- a/src/plopp/backends/matplotlib/line.py
+++ b/src/plopp/backends/matplotlib/line.py
@@ -97,8 +97,6 @@ class Line:
             - ``matplotlib.pyplot.plot`` for data with a non bin-edge coordinate
             - ``matplotlib.pyplot.step`` for data with a bin-edge coordinate
         """
-        has_mask = data["mask"] is not None
-        mask_data_key = "mask" if has_mask else "values"
 
         default_step_style = {
             'linestyle': 'solid',
@@ -114,8 +112,8 @@ class Line:
         }
 
         if data["hist"]:
-            self._line = self._ax.step(data["values"]["x"],
-                                       data["values"]["y"],
+            self._line = self._ax.step(data['values']['x'],
+                                       data['values']['y'],
                                        label=self.label,
                                        zorder=10,
                                        **{
@@ -123,37 +121,37 @@ class Line:
                                            **kwargs
                                        })[0]
 
-            self._mask = self._ax.step(data["values"]["x"], data[mask_data_key]["y"])[0]
+            self._mask = self._ax.step(data['mask']['x'], data['mask']['y'])[0]
             self._mask.update_from(self._line)
             self._mask.set_color(mask_color)
             self._mask.set_label(None)
             self._mask.set_linewidth(self._mask.get_linewidth() * 3)
             self._mask.set_zorder(self._mask.get_zorder() - 1)
-            self._mask.set_visible(has_mask)
+            self._mask.set_visible(data['mask']['visible'])
         else:
-            self._line = self._ax.plot(data["values"]["x"],
-                                       data["values"]["y"],
+            self._line = self._ax.plot(data['values']['x'],
+                                       data['values']['y'],
                                        label=self.label,
                                        zorder=10,
                                        **{
                                            **default_plot_style,
                                            **kwargs
                                        })[0]
-            self._mask = self._ax.plot(data["values"]["x"],
-                                       data[mask_data_key]["y"],
+            self._mask = self._ax.plot(data['mask']['x'],
+                                       data['mask']['y'],
                                        zorder=11,
                                        mec=mask_color,
                                        mfc="None",
                                        mew=3.0,
                                        linestyle="none",
                                        marker=self._line.get_marker(),
-                                       visible=has_mask)[0]
+                                       visible=data['mask']['visible'])[0]
 
         # Add error bars
-        if errorbars and ("e" in data["variances"]):
-            self._error = self._ax.errorbar(data["variances"]["x"],
-                                            data["variances"]["y"],
-                                            yerr=data["variances"]["e"],
+        if errorbars and (data['stddevs'] is not None):
+            self._error = self._ax.errorbar(data['stddevs']['x'],
+                                            data['stddevs']['y'],
+                                            yerr=data['stddevs']['e'],
                                             color=self._line.get_color(),
                                             zorder=10,
                                             fmt="none")
@@ -161,37 +159,38 @@ class Line:
         if self.label:
             self._ax.legend()
 
-    def _preprocess_hist(self, data: dict) -> dict:
-        """
-        Convert 1d data to be plotted to internal format, e.g., padding
-        histograms and duplicating info for variances.
-        """
-        x = data["values"]["x"]
-        y = data["values"]["y"]
-        hist = len(x) != len(y)
-        if hist:
-            data["values"]["y"] = np.concatenate((y[0:1], y))
-            if data["mask"] is not None:
-                data["mask"]["y"] = np.concatenate(
-                    (data["mask"]["y"][0:1], data["mask"]["y"]))
-        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
-
     def _make_data(self) -> dict:
-        data = {"values": {}, "variances": {}, "mask": None}
-        data["values"]["x"] = self._data.meta[self._dim].values
-        data["values"]["y"] = self._data.values
+        x = self._data.meta[self._dim]
+        y = self._data.data
+        hist = len(x) != len(y)
+        error = None
+        mask = {'x': x.values, 'y': y.values, 'visible': False}
         if self._data.variances is not None:
-            data["variances"]["e"] = sc.stddevs(self._data.data).values
+            error = {
+                'x': sc.midpoints(x).values if hist else x.values,
+                'y': y.values,
+                'e': sc.stddevs(y).values
+            }
         if len(self._data.masks):
             one_mask = merge_masks(self._data.masks).values
-            data["mask"] = {
-                "y": np.where(one_mask, data["values"]["y"], None).astype(np.float32)
+            mask = {
+                'x': x.values,
+                'y': np.where(one_mask, y.values, np.nan),
+                'visible': True
             }
-        return self._preprocess_hist(data)
+        if hist:
+            y = sc.concat([y[0:1], y], dim=self._dim)
+            if mask is not None:
+                mask['y'] = np.concatenate([mask['y'][0:1], mask['y']])
+        return {
+            'values': {
+                'x': x.values,
+                'y': y.values
+            },
+            'stddevs': error,
+            'mask': mask,
+            'hist': hist
+        }
 
     def update(self, new_values: sc.DataArray):
         """
@@ -205,19 +204,19 @@ class Line:
         self._data = new_values
         new_values = self._make_data()
 
-        self._line.set_data(new_values["values"]["x"], new_values["values"]["y"])
-        if new_values["mask"] is not None:
-            self._mask.set_data(new_values["values"]["x"], new_values["mask"]["y"])
+        self._line.set_data(new_values['values']['x'], new_values['values']['y'])
+        if new_values['mask']['visible']:
+            self._mask.set_data(new_values['mask']['x'], new_values['mask']['y'])
             self._mask.set_visible(True)
         else:
             self._mask.set_visible(False)
 
-        if (self._error is not None) and ("e" in new_values["variances"]):
+        if (self._error is not None) and (new_values['stddevs'] is not None):
             coll = self._error.get_children()[0]
             coll.set_segments(
-                self._change_segments_y(new_values["variances"]["x"],
-                                        new_values["variances"]["y"],
-                                        new_values["variances"]["e"]))
+                self._change_segments_y(new_values['stddevs']['x'],
+                                        new_values['stddevs']['y'],
+                                        new_values['stddevs']['e']))
 
     def _change_segments_y(self, x: ArrayLike, y: ArrayLike, e: ArrayLike) -> ArrayLike:
         """
diff --git a/src/plopp/backends/plotly/line.py b/src/plopp/backends/plotly/line.py
index 32406fe..11f7fbc 100644
--- a/src/plopp/backends/plotly/line.py
+++ b/src/plopp/backends/plotly/line.py
@@ -91,9 +91,6 @@ class Line:
         import plotly.graph_objects as go
         from plotly.colors import qualitative as plotly_colors
 
-        has_mask = data["mask"] is not None
-        mask_data_key = "mask" if has_mask else "values"
-
         default_colors = plotly_colors.Plotly
         default_line_style = {'color': default_colors[number % len(default_colors)]}
         default_marker_style = {'symbol': number % 53}  # Plotly has 52 marker styles
@@ -107,24 +104,24 @@ class Line:
         marker_style = default_marker_style if marker is None else marker
         line_style = {**default_line_style, **kwargs}
 
-        self._line = go.Scatter(x=np.asarray(data["values"]["x"]),
-                                y=np.asarray(data["values"]["y"]),
+        self._line = go.Scatter(x=np.asarray(data['values']['x']),
+                                y=np.asarray(data['values']['y']),
                                 name=self.label,
                                 mode=mode,
                                 marker=marker_style,
                                 line_shape=line_shape,
                                 line=line_style)
 
-        if errorbars and ("e" in data["variances"]):
-            self._error = go.Scatter(x=np.asarray(data["variances"]["x"]),
-                                     y=np.asarray(data["variances"]["y"]),
+        if errorbars and (data['stddevs'] is not None):
+            self._error = go.Scatter(x=np.asarray(data['stddevs']['x']),
+                                     y=np.asarray(data['stddevs']['y']),
                                      line=line_style,
                                      name=self.label,
                                      mode='markers',
                                      marker={'opacity': 0},
                                      error_y={
                                          'type': 'data',
-                                         'array': data["variances"]["e"]
+                                         'array': data['stddevs']['e']
                                      },
                                      showlegend=False)
 
@@ -140,14 +137,14 @@ class Line:
         if data["hist"]:
             line_style['color'] = mask_color
 
-        self._mask = go.Scatter(x=np.asarray(data["values"]["x"]),
-                                y=np.asarray(data[mask_data_key]["y"]),
+        self._mask = go.Scatter(x=np.asarray(data['mask']['x']),
+                                y=np.asarray(data['mask']['y']),
                                 name=self.label,
                                 mode=mode,
                                 marker=marker_style,
                                 line_shape=line_shape,
                                 line=line_style,
-                                visible=has_mask,
+                                visible=data['mask']['visible'],
                                 showlegend=False)
 
         # Below, we need to re-define the line because it seems that the Scatter trace
@@ -175,37 +172,38 @@ class Line:
         if self._error is not None:
             self._error._plopp_id = self._id
 
-    def _preprocess_hist(self, data: dict) -> dict:
-        """
-        Convert 1d data to be plotted to internal format, e.g., padding
-        histograms and duplicating info for variances.
-        """
-        x = data["values"]["x"]
-        y = data["values"]["y"]
-        hist = len(x) != len(y)
-        if hist:
-            data["values"]["y"] = np.concatenate((y[0:1], y))
-            if data["mask"] is not None:
-                data["mask"]["y"] = np.concatenate(
-                    (data["mask"]["y"][0:1], data["mask"]["y"]))
-        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
-
     def _make_data(self) -> dict:
-        data = {"values": {}, "variances": {}, "mask": None}
-        data["values"]["x"] = self._data.meta[self._dim].values
-        data["values"]["y"] = self._data.values
+        x = self._data.meta[self._dim]
+        y = self._data.data
+        hist = len(x) != len(y)
+        error = None
+        mask = {'x': x.values, 'y': y.values, 'visible': False}
         if self._data.variances is not None:
-            data["variances"]["e"] = sc.stddevs(self._data.data).values
+            error = {
+                'x': sc.midpoints(x).values if hist else x.values,
+                'y': y.values,
+                'e': sc.stddevs(y).values
+            }
         if len(self._data.masks):
             one_mask = merge_masks(self._data.masks).values
-            data["mask"] = {
-                "y": np.where(one_mask, data["values"]["y"], None).astype(np.float32)
+            mask = {
+                'x': x.values,
+                'y': np.where(one_mask, y.values, np.nan),
+                'visible': True
             }
-        return self._preprocess_hist(data)
+        if hist:
+            y = sc.concat([y[0:1], y], dim=self._dim)
+            if mask is not None:
+                mask['y'] = np.concatenate([mask['y'][0:1], mask['y']])
+        return {
+            'values': {
+                'x': x.values,
+                'y': y.values
+            },
+            'stddevs': error,
+            'mask': mask,
+            'hist': hist
+        }
 
     def update(self, new_values: sc.DataArray):
         """
@@ -221,21 +219,21 @@ class Line:
 
         with self._fig.batch_update():
             self._line.update({
-                'x': new_values["values"]["x"],
-                'y': new_values["values"]["y"]
+                'x': new_values['values']['x'],
+                'y': new_values['values']['y']
             })
 
-            if (self._error is not None) and ("e" in new_values["variances"]):
+            if (self._error is not None) and (new_values['stddevs'] is not None):
                 self._error.update({
-                    'x': new_values["variances"]["x"],
-                    'y': new_values["variances"]["y"],
+                    'x': new_values['stddevs']['x'],
+                    'y': new_values['stddevs']['y'],
                     'error_y': {
-                        'array': new_values["variances"]["e"]
+                        'array': new_values['stddevs']['e']
                     }
                 })
 
-            if new_values["mask"] is not None:
-                update = {'x': new_values["values"]["x"], 'y': new_values["mask"]["y"]}
+            if new_values['mask']['visible']:
+                update = {'x': new_values['mask']['x'], 'y': new_values['mask']['y']}
                 self._mask.update(update)
                 self._mask.visible = True
             else:


Test patch:
diff --git a/tests/backends/matplotlib/mpl_line_test.py b/tests/backends/matplotlib/mpl_line_test.py
index 5ef2108..b404b78 100644
--- a/tests/backends/matplotlib/mpl_line_test.py
+++ b/tests/backends/matplotlib/mpl_line_test.py
@@ -40,6 +40,14 @@ def test_line_with_errorbars():
     assert np.allclose(y2, (da.data + sc.stddevs(da.data)).values)
 
 
+def test_line_with_bin_edges_and_errorbars():
+    da = data_array(ndim=1, binedges=True, variances=True)
+    line = Line(canvas=Canvas(), data=da)
+    coll = line._error.get_children()[0]
+    x = np.array(coll.get_segments())[:, 0, 0]
+    assert np.allclose(x, sc.midpoints(da.meta['xx']).values)
+
+
 def test_line_hide_errorbars():
     da = data_array(ndim=1, variances=True)
     line = Line(canvas=Canvas(), data=da, errorbars=False)
@@ -52,6 +60,21 @@ def test_line_with_mask():
     assert line._mask.get_visible()
 
 
+def test_line_with_mask_and_binedges():
+    da = data_array(ndim=1, binedges=True, masks=True)
+    line = Line(canvas=Canvas(), data=da)
+    assert line._mask.get_visible()
+
+
+def test_line_with_two_masks():
+    da = data_array(ndim=1, masks=True)
+    da.masks['two'] = da.coords['xx'] > sc.scalar(25, unit='m')
+    line = Line(canvas=Canvas(), data=da)
+    expected = da.data[da.masks['mask'] | da.masks['two']].values
+    y = line._mask.get_ydata()
+    assert np.allclose(y[~np.isnan(y)], expected)
+
+
 def test_line_update():
     da = data_array(ndim=1)
     line = Line(canvas=Canvas(), data=da)
@@ -80,3 +103,18 @@ def test_line_update_with_errorbars():
     y2 = np.array(coll.get_segments())[:, 1, 1]
     assert np.allclose(y1, (da.data * 2.5 - sc.stddevs(da.data)).values)
     assert np.allclose(y2, (da.data * 2.5 + sc.stddevs(da.data)).values)
+
+
+def test_line_datetime_binedges_with_errorbars():
+    t = np.arange(np.datetime64('2017-03-16T20:58:17'),
+                  np.datetime64('2017-03-16T21:15:17'), 20)
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'] - 1)
+    da = sc.DataArray(data=sc.array(dims=['time'], values=10 * v, variances=v),
+                      coords={'time': time})
+    # Checking for the locations of the errorbars is slightly strange, as the
+    # get_segments() method from the LineCollection gives x values as floats, not
+    # datetime, and comparing with the original data is thus difficult as the floats
+    # do not seem immediately convertible to datetimes.
+    # Hence, we simply check if the Line can be created without raising an exception.
+    Line(canvas=Canvas(), data=da)
diff --git a/tests/backends/plotly/plotly_line_test.py b/tests/backends/plotly/plotly_line_test.py
index 6ece24d..655752a 100644
--- a/tests/backends/plotly/plotly_line_test.py
+++ b/tests/backends/plotly/plotly_line_test.py
@@ -36,6 +36,12 @@ def test_line_with_errorbars():
     assert np.allclose(line._error.error_y['array'], sc.stddevs(da.data).values)
 
 
+def test_line_with_bin_edges_and_errorbars():
+    da = data_array(ndim=1, binedges=True, variances=True)
+    line = Line(canvas=Canvas(), data=da)
+    assert np.allclose(line._error.x, sc.midpoints(da.meta['xx']).values)
+
+
 def test_line_hide_errorbars():
     da = data_array(ndim=1, variances=True)
     line = Line(canvas=Canvas(), data=da, errorbars=False)
@@ -48,6 +54,21 @@ def test_line_with_mask():
     assert line._mask.visible
 
 
+def test_line_with_mask_and_binedges():
+    da = data_array(ndim=1, binedges=True, masks=True)
+    line = Line(canvas=Canvas(), data=da)
+    assert line._mask.visible
+
+
+def test_line_with_two_masks():
+    da = data_array(ndim=1, masks=True)
+    da.masks['two'] = da.coords['xx'] > sc.scalar(25, unit='m')
+    line = Line(canvas=Canvas(), data=da)
+    expected = da.data[da.masks['mask'] | da.masks['two']].values
+    y = line._mask.y
+    assert np.allclose(y[~np.isnan(y)], expected)
+
+
 def test_line_update():
     da = data_array(ndim=1)
     line = Line(canvas=Canvas(), data=da)
@@ -73,3 +94,18 @@ def test_line_update_with_errorbars():
     line.update(new_values)
     assert np.allclose(line._line.y, da.values)
     assert np.allclose(line._error.error_y['array'], sc.stddevs(da.data).values * 2.0)
+
+
+def test_line_datetime_binedges_with_errorbars():
+    t = np.arange(np.datetime64('2017-03-16T20:58:17'),
+                  np.datetime64('2017-03-16T21:15:17'), 20)
+    time = sc.array(dims=['time'], values=t)
+    v = np.random.rand(time.sizes['time'] - 1)
+    da = sc.DataArray(data=sc.array(dims=['time'], values=10 * v, variances=v),
+                      coords={'time': time})
+    xint = t.astype(int)
+    xmid = (0.5 * (xint[1:] + xint[:-1])).astype(int)
+    expected = np.array(xmid, dtype=t.dtype)
+    line = Line(canvas=Canvas(), data=da)
+    # Note that allclose does not work on datetime dtypes
+    assert np.allclose(line._error.x.astype(int), expected.astype(int))