diff --git a/src/plopp/backends/common.py b/src/plopp/backends/common.py index 65f95fda..48ee51a6 100644 --- a/src/plopp/backends/common.py +++ b/src/plopp/backends/common.py @@ -50,6 +50,7 @@ def make_line_data(data: sc.DataArray, dim: str) -> dict: y = data.data hist = len(x) != len(y) error = None + error_x = None xvalues = np.asarray(x.values) yvalues = np.asarray(y.values) values = {'x': xvalues, 'y': yvalues} @@ -66,13 +67,26 @@ def make_line_data(data: sc.DataArray, dim: str) -> dict: if hist: for array in (values, mask): array['y'] = np.concatenate([array['y'][0:1], array['y']]) - return {'values': values, 'stddevs': error, 'mask': mask, 'hist': hist} + if not hist and x.variances is not None: + error_x = { + 'x': xvalues, + 'y': yvalues, + 'e': np.asarray(sc.stddevs(x).values), + } + return { + 'values': values, + 'stddevs': error, + 'stddevs_x': error_x, + 'mask': mask, + 'hist': hist, + } def make_line_bbox( data: sc.DataArray, dim: str, errorbars: bool, + errorbars_x: bool, xscale: Literal['linear', 'log'], yscale: Literal['linear', 'log'], ) -> BoundingBox: @@ -88,12 +102,20 @@ def make_line_bbox( The dimension along which to extract values. errorbars: Whether to include error bars in the bounding box. + errorbars_x: + Whether to include coordinate error bars in the bounding box. xscale: The scale of the x-axis. yscale: The scale of the y-axis. """ line_x = data.coords[dim] + if errorbars_x: + stddevs = sc.stddevs(line_x) + line_x = sc.concat( + [line_x - stddevs, line_x + stddevs], + dim=str(data.dims), + ) if errorbars: stddevs = sc.stddevs(data.data) # Note: [str(data.dims)] is used to make a unique dim name. diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py index 2ee03182..7554e949 100644 --- a/src/plopp/backends/matplotlib/line.py +++ b/src/plopp/backends/matplotlib/line.py @@ -2,7 +2,6 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) import uuid -from enum import Enum from typing import Literal import numpy as np @@ -20,7 +19,28 @@ def _to_float(x): return date2num(x) if np.issubdtype(x.dtype, np.datetime64) else x -ErrorbarMode = Enum("ErrorbarMode", [("band", 1), ("bar", 2)]) +ErrorbarStyle = Literal['band', 'bar'] +ErrorbarAxis = Literal['x', 'y'] +ErrorbarSetting = Literal['band', 'bar', 'xonly', 'yonly', True, False] + + +def _parse_errorbar_setting( + setting: ErrorbarSetting, +) -> dict[ErrorbarAxis, ErrorbarStyle]: + if setting is True: + return {'x': 'bar', 'y': 'bar'} + if setting is False: + return {} + if setting == 'xonly': + return {'x': 'bar'} + if setting in ('yonly', 'bar'): + return {'y': 'bar'} + if setting == 'band': + return {'y': 'band'} + raise ValueError( + f"Invalid errorbars setting: {setting!r}. Expected one of " + "True, False, 'band', 'bar', 'xonly', or 'yonly'." + ) def _fill_between(ax, x, y, e, color, zorder, alpha, hist): @@ -42,7 +62,8 @@ class Errorbars: def __init__( self, - mode: Literal["band", "bar"], + mode: ErrorbarStyle, + axis: ErrorbarAxis, ax: Axes, x: np.ndarray, y: np.ndarray, @@ -52,27 +73,36 @@ def __init__( alpha: float, hist: bool, ): - self._mode = ErrorbarMode[mode] + self._mode = mode + self._axis = axis self._ax = ax - if self._mode == ErrorbarMode.band: + if self._mode == 'band': + if self._axis != 'y': + raise ValueError("Error bands are only supported along the y-axis") self._artist = _fill_between( ax, x, y, e, color=color, zorder=zorder, alpha=alpha, hist=hist ) - elif self._mode == ErrorbarMode.bar: + elif self._mode == 'bar': if hist: # Use bin centers for bars; We go via sc.midpoints as it handles # datetime coordinates correctly. x = np.asarray(sc.midpoints(sc.array(dims='x', values=x)).values) self._artist = ax.errorbar( - x, y, yerr=e, color=color, zorder=zorder, fmt="none" + x, + y, + xerr=e if self._axis == 'x' else None, + yerr=e if self._axis == 'y' else None, + color=color, + zorder=zorder, + fmt="none", ) else: raise ValueError(f"Invalid errorbar mode: {mode}") def update(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, hist: bool) -> None: - yme = y - e - ype = y + e - if self._mode == ErrorbarMode.band: + if self._mode == 'band': + yme = y - e + ype = y + e verts = self._artist.get_paths()[0].vertices # In the case of bin-edge histogram, we have more vertices in the step # function: 4 * len(y) + 4. In the case of bin centers, the fill using lines @@ -105,85 +135,97 @@ def update(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, hist: bool) -> Non verts[:, 0] = _to_float(xverts) verts[:, 1] = yverts else: - # Note that we only need to convert the x values to float if they are - # datetime, as the y values are always floats (variances on data with - # datetime dtype is not supported in scipp). - x = _to_float(x) + if self._axis == 'x': + x = np.asarray(self._ax.convert_xunits(x)) + y = np.asarray(self._ax.convert_yunits(y)) + else: + # For vertical errors, y is always numeric because Scipp only supports + # variances for numeric data. Only datetime x values need conversion. + x = _to_float(x) if hist: x = 0.5 * (x[1:] + x[:-1]) # Use bin centers for bars - coll = self._artist.get_children()[0] - arr1 = np.repeat(x, 2) - arr2 = np.array([yme, ype]).T.flatten() - coll.set_segments(np.array([arr1, arr2]).T.flatten().reshape(len(y), 2, 2)) + if self._axis == 'x': + lower = np.column_stack((x - e, y)) + upper = np.column_stack((x + e, y)) + else: + lower = np.column_stack((x, y - e)) + upper = np.column_stack((x, y + e)) + self._barline_collection.set_segments(np.stack((lower, upper), axis=1)) + caps = self._artist.lines[1] + if caps: + for cap, endpoints in zip(caps, (lower, upper), strict=True): + cap.set_data(endpoints[:, 0], endpoints[:, 1]) + + @property + def _barline_collection(self): + return self._artist.lines[2][0] def remove(self): self._artist.remove() def get_color(self) -> str: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': return self._artist.get_facecolor()[0] else: - return self._artist.get_children()[0].get_color() + return self._barline_collection.get_color() def set_color(self, color): - if self._mode == ErrorbarMode.band: + if self._mode == 'band': self._artist.set_facecolor(color) else: for artist in self._artist.get_children(): artist.set_color(color) def get_visible(self) -> bool: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': return self._artist.get_visible() else: - return self._artist.get_children()[0].get_visible() + return self._barline_collection.get_visible() def set_visible(self, visible): - if self._mode == ErrorbarMode.band: + if self._mode == 'band': self._artist.set_visible(visible) else: for artist in self._artist.get_children(): artist.set_visible(visible) def get_alpha(self) -> float: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': return self._artist.get_alpha() else: - return self._artist.get_children()[0].get_alpha() + return self._barline_collection.get_alpha() def set_alpha(self, alpha): - if self._mode == ErrorbarMode.band: + if self._mode == 'band': self._artist.set_alpha(alpha) else: for artist in self._artist.get_children(): artist.set_alpha(alpha) def get_zorder(self) -> float: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': return self._artist.get_zorder() else: - return self._artist.get_children()[0].get_zorder() + return self._barline_collection.get_zorder() def set_zorder(self, zorder): - if self._mode == ErrorbarMode.band: + if self._mode == 'band': self._artist.set_zorder(zorder) else: for artist in self._artist.get_children(): artist.set_zorder(zorder) def get_xdata(self) -> np.ndarray: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': return self._artist.get_paths()[0].vertices[:, 0] else: - coll = self._artist.get_children()[0] - return np.array(coll.get_segments())[:, :, 0] + return np.array(self._barline_collection.get_segments())[:, :, 0] def get_ydata(self) -> np.ndarray: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': return self._artist.get_paths()[0].vertices[:, 1] else: - coll = self._artist.get_children()[0] - return np.array(coll.get_segments())[:, :, 1] + return np.array(self._barline_collection.get_segments())[:, :, 1] class Line: @@ -204,8 +246,11 @@ class Line: The canvas keeps track of how many lines have been added to it. This number is used to set the color and marker parameters of the line. errorbars: - Whether to add error bars to the line. Optionally, this can be a string to - specify the error bar style. Valid values are 'band' and 'bar'. + Which error bars to add to the line. ``True`` displays both coordinate and data + error bars. ``False`` hides all error bars. ``'xonly'`` and ``'yonly'`` display + bars only for the selected axis, while ``'bar'`` and ``'band'`` display data + error bars using the requested style. Coordinate error bars are ignored for + bin-edge coordinates. mask_color: The color of the masked points. """ @@ -216,7 +261,7 @@ def __init__( data: sc.DataArray, uid: str | None = None, artist_number: int = 0, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: ErrorbarSetting = True, mask_color: str | None = None, **kwargs, ): @@ -225,19 +270,14 @@ def __init__( self._canvas = canvas self._ax = self._canvas.ax self._data = data - if errorbars is True: - errorbars = 'bar' + self._errorbar_modes = _parse_errorbar_setting(errorbars) line_args = parse_dicts_in_kwargs(kwargs, name=data.name) - self._line = None - self._mask = None self._error = None - self._unit = None + self._error_x = None self.label = data.name self._dim = self._data.dim - self._unit = self._data.unit - self._coord = self._data.coords[self._dim] if mask_color is None: mask_color = 'black' @@ -297,19 +337,43 @@ def __init__( lw=self._line.get_linewidth() * 3, zorder=self._line.get_zorder() - 1 ) - # Add error bars - if errorbars and (line_data['stddevs'] is not None): - self._error = Errorbars( - mode=errorbars, - ax=self._ax, - x=line_data['stddevs']['x'], - y=line_data['stddevs']['y'], - e=line_data['stddevs']['e'], - color=self._line.get_color(), - zorder=self._line.get_zorder(), - alpha=(({self._line.get_alpha()} - {None}) or {1.0}).pop() * 0.3, - hist=line_data['hist'], - ) + self._sync_errorbars(line_data) + + def _make_errorbar(self, *, mode, axis, data, hist): + return Errorbars( + mode=mode, + axis=axis, + ax=self._ax, + x=data['x'], + y=data['y'], + e=data['e'], + color=self._line.get_color(), + zorder=self._line.get_zorder(), + alpha=(({self._line.get_alpha()} - {None}) or {1.0}).pop() * 0.3, + hist=hist, + ) + + def _sync_errorbar(self, errorbar, *, axis, data, hist): + mode = self._errorbar_modes.get(axis) + if mode is None or data is None: + if errorbar is not None: + errorbar.remove() + return None + if errorbar is None: + errorbar = self._make_errorbar(mode=mode, axis=axis, data=data, hist=hist) + errorbar.set_visible(self.visible) + else: + errorbar.update(x=data['x'], y=data['y'], e=data['e'], hist=hist) + return errorbar + + def _sync_errorbars(self, line_data): + hist = line_data['hist'] + self._error = self._sync_errorbar( + self._error, axis='y', data=line_data['stddevs'], hist=hist + ) + self._error_x = self._sync_errorbar( + self._error_x, axis='x', data=line_data['stddevs_x'], hist=hist + ) def update(self, new_values: sc.DataArray): """ @@ -328,13 +392,7 @@ def update(self, new_values: sc.DataArray): self._mask.set_data(line_data['mask']['x'], line_data['mask']['y']) self._mask.set_visible(line_data['mask']['visible']) - if (self._error is not None) and (line_data['stddevs'] is not None): - self._error.update( - x=line_data['stddevs']['x'], - y=line_data['stddevs']['y'], - e=line_data['stddevs']['e'], - hist=line_data['hist'], - ) + self._sync_errorbars(line_data) def remove(self): """ @@ -342,8 +400,9 @@ def remove(self): """ self._line.remove() self._mask.remove() - if self._error is not None: - self._error.remove() + for error in (self._error, self._error_x): + if error is not None: + error.remove() self._canvas.draw() @property @@ -356,8 +415,9 @@ def color(self) -> str: @color.setter def color(self, val: str): self._line.set_color(val) - if self._error is not None: - self._error.set_color(val) + for error in (self._error, self._error_x): + if error is not None: + error.set_color(val) self._canvas.draw() @property @@ -408,8 +468,9 @@ def visible(self) -> bool: def visible(self, val: bool): self._line.set_visible(val) self._mask.set_visible(val) - if self._error is not None: - self._error.set_visible(val) + for error in (self._error, self._error_x): + if error is not None: + error.set_visible(val) self._canvas.draw() @property @@ -423,8 +484,9 @@ def opacity(self) -> float: def opacity(self, val: float): self._line.set_alpha(val) self._mask.set_alpha(val) - if self._error is not None: - self._error.set_alpha(val) + for error in (self._error, self._error_x): + if error is not None: + error.set_alpha(val) self._canvas.draw() def bbox( @@ -445,6 +507,7 @@ def bbox( data=self._data, dim=self._dim, errorbars=self._error is not None, + errorbars_x=self._error_x is not None, xscale=xscale, yscale=yscale, ) diff --git a/src/plopp/core/utils.py b/src/plopp/core/utils.py index 88a2d5e1..e3323dd4 100644 --- a/src/plopp/core/utils.py +++ b/src/plopp/core/utils.py @@ -31,6 +31,8 @@ def coord_as_bin_edges( x = sc.arange(dim, float(x.shape[0]), unit=x.unit) if da.coords.is_edges(key, dim=dim): return x + if x.variances is not None: + x = sc.values(x) if x.dtype in ('int32', 'int64'): x = x.to(dtype='float64') if x.sizes[dim] < 2: diff --git a/src/plopp/plotting/_inspector.py b/src/plopp/plotting/_inspector.py index bd02d11d..1f8fe0cb 100644 --- a/src/plopp/plotting/_inspector.py +++ b/src/plopp/plotting/_inspector.py @@ -145,7 +145,7 @@ def inspector( cmin: sc.Variable | float | None = None, continuous_update: bool = True, coords: list[str] | None = None, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -246,8 +246,11 @@ def inspector( coords: If supplied, use these coords instead of the input's dimension coordinates. errorbars: - Whether to add error bars to the line. Optionally, this can be a string to - specify the error bar style. Valid values are 'band' and 'bar' (1d figure). + Which error bars to add to the line. ``True`` displays both coordinate and data + error bars. ``False`` hides all error bars. ``'xonly'`` and ``'yonly'`` display + bars only for the selected axis, while ``'bar'`` and ``'band'`` display data + error bars using the requested style (1d figure). Coordinate error bars are + ignored for bin-edge coordinates. figsize: The width and height of the figure, in inches. grid: diff --git a/src/plopp/plotting/_plot.py b/src/plopp/plotting/_plot.py index 5ce30351..7f171e11 100644 --- a/src/plopp/plotting/_plot.py +++ b/src/plopp/plotting/_plot.py @@ -27,7 +27,7 @@ def plot( cmax: sc.Variable | float | None = None, cmin: sc.Variable | float | None = None, coords: list[str] | None = None, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, ignore_size: bool = False, @@ -74,8 +74,11 @@ def plot( coords: If supplied, use these coords instead of the input's dimension coordinates. errorbars: - Whether to add error bars to the line. Optionally, this can be a string to - specify the error bar style. Valid values are 'band' and 'bar'. + Which error bars to add to the line. ``True`` displays both coordinate and data + error bars. ``False`` hides all error bars. ``'xonly'`` and ``'yonly'`` display + bars only for the selected axis, while ``'bar'`` and ``'band'`` display data + error bars using the requested style. Coordinate error bars are ignored for + bin-edge coordinates. figsize: The width and height of the figure, in inches. grid: diff --git a/src/plopp/plotting/_slicer.py b/src/plopp/plotting/_slicer.py index 01ca6d5e..0c16594a 100644 --- a/src/plopp/plotting/_slicer.py +++ b/src/plopp/plotting/_slicer.py @@ -257,7 +257,7 @@ def slicer( cmin: sc.Variable | float | None = None, coords: list[str] | None = None, enable_player: bool = False, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -316,8 +316,11 @@ def slicer( If ``True``, add a play button to the sliders to automatically step through the slices. errorbars: - Whether to add error bars to the line. Optionally, this can be a string to - specify the error bar style. Valid values are 'band' and 'bar'. + Which error bars to add to the line. ``True`` displays both coordinate and data + error bars. ``False`` hides all error bars. ``'xonly'`` and ``'yonly'`` display + bars only for the selected axis, while ``'bar'`` and ``'band'`` display data + error bars using the requested style. Coordinate error bars are ignored for + bin-edge coordinates. figsize: The width and height of the figure, in inches. grid: diff --git a/src/plopp/plotting/_superplot.py b/src/plopp/plotting/_superplot.py index 902cfc14..f6537281 100644 --- a/src/plopp/plotting/_superplot.py +++ b/src/plopp/plotting/_superplot.py @@ -18,7 +18,7 @@ def superplot( autoscale: bool = True, coords: list[str] | None = None, enable_player: bool = False, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -62,8 +62,11 @@ def superplot( If ``True``, add a play button to the sliders to automatically step through the slices. errorbars: - Whether to add error bars to the line. Optionally, this can be a string to - specify the error bar style. Valid values are 'band' and 'bar'. + Which error bars to add to the line. ``True`` displays both coordinate and data + error bars. ``False`` hides all error bars. ``'xonly'`` and ``'yonly'`` display + bars only for the selected axis, while ``'bar'`` and ``'band'`` display data + error bars using the requested style. Coordinate error bars are ignored for + bin-edge coordinates. figsize: The width and height of the figure, in inches. grid: diff --git a/src/plopp/plotting/_xyplot.py b/src/plopp/plotting/_xyplot.py index 54f24bcf..da6cf008 100644 --- a/src/plopp/plotting/_xyplot.py +++ b/src/plopp/plotting/_xyplot.py @@ -42,7 +42,7 @@ def xyplot( y: sc.Variable | ndarray | list | Node, aspect: Literal['auto', 'equal'] | None = None, autoscale: bool = True, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -78,8 +78,11 @@ def xyplot( autoscale: Automatically scale the axes on updates if ``True``. errorbars: - Whether to add error bars to the line. Optionally, this can be a string to - specify the error bar style. Valid values are 'band' and 'bar'. + Which error bars to add to the line. ``True`` displays both ``x`` and ``y`` + error bars. ``False`` hides all error bars. ``'xonly'`` and ``'yonly'`` display + bars only for the selected axis, while ``'bar'`` and ``'band'`` display ``y`` + error bars using the requested style. ``x`` error bars are ignored when ``x`` + contains bin edges. figsize: The width and height of the figure, in inches. grid: diff --git a/src/plopp/plotting/common.py b/src/plopp/plotting/common.py index 60d18fea..59d8e44a 100644 --- a/src/plopp/plotting/common.py +++ b/src/plopp/plotting/common.py @@ -165,6 +165,8 @@ def _all_dims_sorted(var, order='ascending') -> bool: Check if all dimensions of a variable are sorted in the specified order. This is used to ensure that the coordinates are sorted before plotting. """ + if var.variances is not None: + var = sc.values(var) return all(sc.allsorted(var, dim, order=order) for dim in var.dims) @@ -359,7 +361,7 @@ def categorize_args( cmap: str = 'viridis', cmax: sc.Variable | float | None = None, cmin: sc.Variable | float | None = None, - errorbars: Literal['band', 'bar', True, False] = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, diff --git a/tests/backends/matplotlib/mpl_line_test.py b/tests/backends/matplotlib/mpl_line_test.py index 5f9e31e7..d4dc7dce 100644 --- a/tests/backends/matplotlib/mpl_line_test.py +++ b/tests/backends/matplotlib/mpl_line_test.py @@ -4,6 +4,7 @@ import numpy as np import pytest import scipp as sc +from matplotlib import rc_context from matplotlib.markers import MarkerStyle from plopp.backends.matplotlib.canvas import Canvas @@ -13,10 +14,15 @@ pytestmark = pytest.mark.usefixtures("_parametrize_mpl_backends") +def _with_coord_variances(da): + coord = da.coords[da.dim] + coord.variances = np.linspace(0.1, 0.5, len(coord)) ** 2 + return da + + def test_line_creation(): - da = data_array(ndim=1, unit='K') + da = data_array(ndim=1) line = Line(canvas=Canvas(), data=da) - assert line._unit == 'K' assert line._dim == 'xx' assert len(line._line.get_xdata()) == da.sizes['xx'] assert np.allclose(line._line.get_xdata(), da.coords['xx'].values) @@ -71,6 +77,108 @@ def test_line_hide_errorbars(): assert line._error is None +def test_coordinate_errorbars_are_enabled_by_default(): + da = _with_coord_variances(data_array(ndim=1)) + line = Line(canvas=Canvas(), data=da) + assert line._error_x is not None + + +def test_invalid_errorbar_setting_raises(): + da = _with_coord_variances(data_array(ndim=1)) + with pytest.raises(ValueError, match="Invalid errorbars setting"): + Line(canvas=Canvas(), data=da, errorbars='invalid') + + +def test_line_with_coordinate_errorbars(): + da = _with_coord_variances(data_array(ndim=1)) + line = Line(canvas=Canvas(), data=da, errorbars='xonly') + + coord = da.coords[da.dim] + assert np.allclose( + line._error_x.get_xdata().min(), (coord - sc.stddevs(coord)).values.min() + ) + assert np.allclose( + line._error_x.get_xdata().max(), (coord + sc.stddevs(coord)).values.max() + ) + + +def test_line_skips_coordinate_errorbars_for_bin_edges(): + da = _with_coord_variances(data_array(ndim=1, binedges=True)) + line = Line(canvas=Canvas(), data=da, errorbars='xonly') + assert line._error_x is None + + +@pytest.mark.parametrize( + ('setting', 'has_x', 'y_style'), + [ + (True, True, 'bar'), + (False, False, None), + ('xonly', True, None), + ('yonly', False, 'bar'), + ('bar', False, 'bar'), + ('band', False, 'band'), + ], +) +def test_errorbar_setting_selects_axes_and_style(setting, has_x, y_style): + da = _with_coord_variances(data_array(ndim=1, variances=True)) + line = Line(canvas=Canvas(), data=da, errorbars=setting) + assert (line._error_x is not None) is has_x + if line._error_x is not None: + assert line._error_x._mode == 'bar' + assert (line._error is not None) is (y_style is not None) + if line._error is not None: + assert line._error._mode == y_style + + +def test_line_update_coordinate_errorbars_with_string_data(): + coord = sc.array(dims=['x'], values=[1.0, 2.0], variances=[0.1, 0.2]) + da = sc.DataArray(sc.array(dims=['x'], values=['a', 'b']), coords={'x': coord}) + line = Line(canvas=Canvas(), data=da, errorbars='xonly') + line.update(da) + assert np.array_equal(line._error_x.get_ydata(), [[0.0, 0.0], [1.0, 1.0]]) + + +def test_line_update_coordinate_errorbars_with_caps(): + da = _with_coord_variances(data_array(ndim=1)) + updated = da.copy(deep=True) + updated.coords[updated.dim] += sc.scalar(2.0, unit=updated.coords[updated.dim].unit) + updated.values += 1.0 + with rc_context({'errorbar.capsize': 3.0}): + line = Line(canvas=Canvas(), data=da, errorbars='xonly') + line.update(updated) + coord = updated.coords[updated.dim] + lower_x, upper_x = line._error_x._artist.lines[1] + assert np.allclose(lower_x.get_xdata(), (coord - sc.stddevs(coord)).values) + assert np.allclose(upper_x.get_xdata(), (coord + sc.stddevs(coord)).values) + assert np.allclose(lower_x.get_ydata(), updated.values) + assert np.allclose(upper_x.get_ydata(), updated.values) + + +def test_line_update_data_errorbars_with_caps(): + da = data_array(ndim=1, variances=True) + updated = da.copy(deep=True) + updated.coords[updated.dim] += sc.scalar(2.0, unit=updated.coords[updated.dim].unit) + updated.values += 1.0 + with rc_context({'errorbar.capsize': 3.0}): + line = Line(canvas=Canvas(), data=da, errorbars='yonly') + line.update(updated) + coord = updated.coords[updated.dim] + lower_y, upper_y = line._error._artist.lines[1] + assert np.allclose(lower_y.get_xdata(), coord.values) + assert np.allclose(upper_y.get_xdata(), coord.values) + assert np.allclose(lower_y.get_ydata(), (updated - sc.stddevs(updated)).values) + assert np.allclose(upper_y.get_ydata(), (updated + sc.stddevs(updated)).values) + + +def test_line_bbox_includes_coordinate_errorbars(): + coord = sc.array(dims=['x'], values=[0.0, 1.0], variances=[4.0, 4.0]) + da = sc.DataArray(sc.arange('x', 2.0), coords={'x': coord}) + line = Line(canvas=Canvas(), data=da, errorbars='xonly') + bbox = line.bbox(xscale='linear', yscale='linear') + assert bbox.xmin < -2.0 + assert bbox.xmax > 3.0 + + def test_line_with_mask(): da = data_array(ndim=1, masks=True) line = Line(canvas=Canvas(), data=da) @@ -132,6 +240,62 @@ def test_line_update_with_errorbars(mode): ) +def test_line_update_with_bin_edges_and_errorbars(): + da = data_array(ndim=1, binedges=True, variances=True) + line = Line(canvas=Canvas(), data=da) + line.update(da * 2.5) + + x = sc.midpoints(da.coords[da.dim]).values + assert np.allclose(line._error.get_xdata().min(), x.min()) + assert np.allclose(line._error.get_xdata().max(), x.max()) + + +@pytest.mark.parametrize(('setting', 'has_x'), [(True, True), ('band', False)]) +def test_line_update_adds_and_removes_errorbars(setting, has_x): + da = data_array(ndim=1) + line = Line(canvas=Canvas(), data=da, errorbars=setting) + assert line._error is None + assert line._error_x is None + + line.visible = False + with_variances = _with_coord_variances(data_array(ndim=1, variances=True)) + line.update(with_variances) + assert line._error is not None + assert not line._error.get_visible() + assert (line._error_x is not None) is has_x + if line._error_x is not None: + assert not line._error_x.get_visible() + + line.update(da) + assert line._error is None + assert line._error_x is None + line.bbox(xscale='linear', yscale='linear') + + line.update(with_variances) + assert line._error is not None + assert (line._error_x is not None) is has_x + + +def test_initial_errorbars_follow_line_visibility(): + da = _with_coord_variances(data_array(ndim=1, variances=True)) + line = Line(canvas=Canvas(), data=da, visible=False) + assert not line._error.get_visible() + assert not line._error_x.get_visible() + + +def test_line_update_removes_coordinate_errorbars_for_bin_edges(): + points = _with_coord_variances(data_array(ndim=1)) + bin_edges = _with_coord_variances(data_array(ndim=1, binedges=True)) + line = Line(canvas=Canvas(), data=points) + assert line._error_x is not None + + line.update(bin_edges) + assert line._error_x is None + + line.update(points) + assert line._error_x is not None + + @pytest.mark.parametrize("mode", ['band', 'bar', True]) def test_line_datetime_binedges_with_errorbars(mode): t = np.arange( diff --git a/tests/core/utils_test.py b/tests/core/utils_test.py index 2b397919..220e2361 100644 --- a/tests/core/utils_test.py +++ b/tests/core/utils_test.py @@ -15,6 +15,14 @@ def test_coord_as_bin_edges_midpoints_input(): assert sc.identical(result, sc.linspace('x', -0.5, 4.5, num=6, unit='m')) +def test_coord_as_bin_edges_midpoints_with_variances_uses_values(): + coord = sc.array(dims=['x'], values=[1.0, 2.0, 4.0], variances=[0.1, 0.2, 0.3]) + da = sc.DataArray(sc.ones(dims=['x'], shape=[3]), coords={'x': coord}) + result = coord_as_bin_edges(da, key='x') + expected = sc.array(dims=['x'], values=[0.5, 1.5, 3.0, 5.0]) + assert sc.identical(result, expected) + + def test_coord_as_bin_edges_edges_input(): x = sc.arange('x', 6.0, unit='m') da = sc.DataArray(data=sc.arange('x', 5.0, unit='K'), coords={'x': x}) diff --git a/tests/plotting/inspector_test.py b/tests/plotting/inspector_test.py index bf4e46d7..118dbea6 100644 --- a/tests/plotting/inspector_test.py +++ b/tests/plotting/inspector_test.py @@ -64,6 +64,20 @@ def test_line_creation(): assert len(fig1d.artists) == 2 +@pytest.mark.usefixtures('_use_ipympl') +def test_line_with_coordinate_errorbars_by_default(): + da = pp.data.data3d() + dim = da.dims[-1] + da.coords[dim].variances = np.full(da.sizes[dim], 0.25) + ip = pp.inspector(da, dim=dim) + fig2d = ip[0][0] + fig1d = ip[0][1] + fig2d.toolbar['inspect'].value = True + fig2d.toolbar['inspect']._tool.click(10, 10) + [line] = fig1d.artists.values() + assert line._error_x is not None + + @pytest.mark.usefixtures('_use_ipympl') def test_line_removal(): da = pp.data.data3d() diff --git a/tests/plotting/plot_1d_test.py b/tests/plotting/plot_1d_test.py index 19d4fe2e..78fc82d1 100644 --- a/tests/plotting/plot_1d_test.py +++ b/tests/plotting/plot_1d_test.py @@ -449,6 +449,33 @@ def test_plot_1d_data_with_errorbars_auto(): assert p.canvas.ymax > 1.0 +@pytest.mark.parametrize( + ('setting', 'enabled'), + [ + (True, True), + (False, False), + ('xonly', True), + ('yonly', False), + ('bar', False), + ('band', False), + ], +) +def test_plot_1d_data_with_coordinate_errorbars(setting, enabled): + da = data_array(ndim=1) + da.coords[da.dim].variances = np.full(da.sizes[da.dim], 0.25) + p = da.plot(errorbars=setting) + [line] = p.artists.values() + assert (line._error_x is not None) is enabled + + +def test_plot_1d_data_with_coordinate_errorbars_by_default(): + da = data_array(ndim=1) + da.coords[da.dim].variances = np.full(da.sizes[da.dim], 0.25) + p = da.plot() + [line] = p.artists.values() + assert line._error_x is not None + + @pytest.mark.parametrize("mode", ['band', 'bar', True]) def test_plot_1d_data_with_variances_and_nan_values(mode): da = data_array(ndim=1, variances=True) diff --git a/tests/plotting/slicer_test.py b/tests/plotting/slicer_test.py index 72d8267d..72a1c3a8 100644 --- a/tests/plotting/slicer_test.py +++ b/tests/plotting/slicer_test.py @@ -113,6 +113,13 @@ def test_no_keep_with_figure(self): sp = SlicerPlot(da) assert 'yy' in sp.slicer.slider.controls + def test_with_coordinate_errorbars_by_default(self): + da = data_array(ndim=2) + da.coords['xx'].variances = np.full(da.sizes['xx'], 0.25) + sp = SlicerPlot(da, keep=['xx'], mode='single') + [line] = sp.figure.artists.values() + assert line._error_x is not None + def test_with_dataset(self): ds = dataset(ndim=2) sl = DimensionSlicer(ds, keep=['xx'], mode='single') diff --git a/tests/plotting/superplot_test.py b/tests/plotting/superplot_test.py index b928ec86..46f55b8d 100644 --- a/tests/plotting/superplot_test.py +++ b/tests/plotting/superplot_test.py @@ -15,6 +15,14 @@ def test_creation(): assert len(sp.right_bar[0]._lines) == 0 +def test_coordinate_errorbars_by_default(): + da = data_array(ndim=2) + da.coords['xx'].variances = da.coords['xx'].values * 0.0 + 0.25 + sp = superplot(da, keep='xx') + [line] = sp.artists.values() + assert line._error_x is not None + + def test_from_node(): da = data_array(ndim=2) superplot(Node(da)) diff --git a/tests/plotting/xyplot_test.py b/tests/plotting/xyplot_test.py index a4ff31af..a34520a9 100644 --- a/tests/plotting/xyplot_test.py +++ b/tests/plotting/xyplot_test.py @@ -27,6 +27,15 @@ def test_xyplot_woth_variances(): assert line._error is not None +def test_xyplot_with_x_variances_uses_errorbars_by_default(): + x = sc.arange('time', 20.0, unit='s') + x.variances = np.full(x.sizes['time'], 0.25) + y = sc.arange('time', 100.0, 120.0, unit='K') + fig = pp.xyplot(x, y) + [line] = fig.artists.values() + assert line._error_x is not None + + def test_xyplot_ndarray(): N = 50 x = np.arange(float(N)) @@ -75,11 +84,13 @@ def test_xyplot_variable_kwargs(): def test_xyplot_bin_edges(): x = sc.arange('time', 21.0, unit='s') + x.variances = np.full(x.sizes['time'], 0.25) y = sc.arange('time', 100.0, 120.0, unit='K') fig = pp.xyplot(x, y) [line] = fig.artists.values() ldata = line._data assert len(ldata.coords[ldata.dim]) == len(ldata.data) + 1 + assert line._error_x is None def test_xyplot_from_nodes():