From 911886a4b4ff3773fca3ad9a17337107a67c8107 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 10 Aug 2026 08:11:17 +0200 Subject: [PATCH 1/6] feat: adds optional horizontal error bars --- src/plopp/backends/common.py | 28 +++- src/plopp/backends/matplotlib/line.py | 142 +++++++++++++++------ src/plopp/core/utils.py | 5 + src/plopp/plotting/_inspector.py | 4 + src/plopp/plotting/_plot.py | 4 + src/plopp/plotting/_slicer.py | 4 + src/plopp/plotting/_superplot.py | 4 + src/plopp/plotting/_xyplot.py | 4 + src/plopp/plotting/common.py | 10 +- tests/backends/matplotlib/mpl_line_test.py | 102 +++++++++++++++ tests/core/utils_test.py | 8 ++ tests/plotting/inspector_test.py | 14 ++ tests/plotting/plot_1d_test.py | 9 ++ tests/plotting/slicer_test.py | 7 + tests/plotting/superplot_test.py | 8 ++ tests/plotting/xyplot_test.py | 9 ++ 16 files changed, 319 insertions(+), 43 deletions(-) diff --git a/src/plopp/backends/common.py b/src/plopp/backends/common.py index 65f95fda7..d2925dd0f 100644 --- a/src/plopp/backends/common.py +++ b/src/plopp/backends/common.py @@ -31,7 +31,7 @@ def check_ndim(data: sc.DataArray, ndim: int, origin: str) -> None: ) -def make_line_data(data: sc.DataArray, dim: str) -> dict: +def make_line_data(data: sc.DataArray, dim: str, errorbars_x: bool = False) -> dict: """ Prepare data for plotting a line. This includes extracting the x and y values, and optionally the error bars and masks @@ -45,11 +45,14 @@ def make_line_data(data: sc.DataArray, dim: str) -> dict: The data array to extract values from. dim: The dimension along which to extract values. + errorbars_x: + Whether to extract coordinate standard deviations. """ x = data.coords[dim] 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 +69,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 errorbars_x and x.variances is not None: + error_x = { + 'x': xvalues, + 'y': values['y'], + '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 +104,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 2ee03182a..762829352 100644 --- a/src/plopp/backends/matplotlib/line.py +++ b/src/plopp/backends/matplotlib/line.py @@ -43,6 +43,7 @@ class Errorbars: def __init__( self, mode: Literal["band", "bar"], + axis: Literal['x', 'y'], ax: Axes, x: np.ndarray, y: np.ndarray, @@ -53,26 +54,35 @@ def __init__( hist: bool, ): self._mode = ErrorbarMode[mode] + self._axis = axis self._ax = ax if self._mode == ErrorbarMode.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: - if hist: + if hist and self._axis == 'y': # 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: + 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,16 +115,28 @@ 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 hist: + if self._axis == 'x': + x = np.asarray(self._ax.convert_xunits(x)) + y = np.asarray(self._ax.convert_yunits(y)) + else: + x = _to_float(x) + if hist and self._axis == 'y': 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() @@ -123,7 +145,7 @@ def get_color(self) -> str: if self._mode == ErrorbarMode.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: @@ -136,7 +158,7 @@ def get_visible(self) -> bool: if self._mode == ErrorbarMode.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: @@ -149,7 +171,7 @@ def get_alpha(self) -> float: if self._mode == ErrorbarMode.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: @@ -162,7 +184,7 @@ def get_zorder(self) -> float: if self._mode == ErrorbarMode.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: @@ -175,15 +197,13 @@ def get_xdata(self) -> np.ndarray: if self._mode == ErrorbarMode.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: 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: @@ -206,6 +226,8 @@ class 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'. + errorbars_x: + Whether to add error bars from coordinate variances to the line. mask_color: The color of the masked points. """ @@ -217,6 +239,7 @@ def __init__( uid: str | None = None, artist_number: int = 0, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, mask_color: str | None = None, **kwargs, ): @@ -227,12 +250,16 @@ def __init__( self._data = data if errorbars is True: errorbars = 'bar' + if not isinstance(errorbars_x, bool): + raise TypeError("errorbars_x must be True or False") + self._errorbars_x = errorbars_x line_args = parse_dicts_in_kwargs(kwargs, name=data.name) self._line = None self._mask = None self._error = None + self._error_x = None self._unit = None self.label = data.name self._dim = self._data.dim @@ -246,7 +273,9 @@ def __init__( if key in line_args: line_args[alias] = line_args.pop(key) - line_data = make_line_data(data=self._data, dim=self._dim) + line_data = make_line_data( + data=self._data, dim=self._dim, errorbars_x=self._errorbars_x + ) default_step_style = { 'linestyle': 'solid', @@ -297,19 +326,44 @@ 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( + self._error = self._make_errorbar( 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, + axis='y', + data=line_data['stddevs'], hist=line_data['hist'], ) + self._sync_errorbars_x(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_errorbars_x(self, line_data): + data = line_data['stddevs_x'] + if data is None: + if self._error_x is not None: + self._error_x.remove() + self._error_x = None + elif self._error_x is None: + self._error_x = self._make_errorbar( + mode='bar', axis='x', data=data, hist=line_data['hist'] + ) + self._error_x.set_visible(self.visible) + else: + self._error_x.update( + x=data['x'], y=data['y'], e=data['e'], hist=line_data['hist'] + ) def update(self, new_values: sc.DataArray): """ @@ -322,7 +376,9 @@ def update(self, new_values: sc.DataArray): """ check_ndim(new_values, ndim=1, origin='Line') self._data = new_values - line_data = make_line_data(data=self._data, dim=self._dim) + line_data = make_line_data( + data=self._data, dim=self._dim, errorbars_x=self._errorbars_x + ) self._line.set_data(line_data['values']['x'], line_data['values']['y']) self._mask.set_data(line_data['mask']['x'], line_data['mask']['y']) @@ -335,6 +391,7 @@ def update(self, new_values: sc.DataArray): e=line_data['stddevs']['e'], hist=line_data['hist'], ) + self._sync_errorbars_x(line_data) def remove(self): """ @@ -342,8 +399,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 +414,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 +467,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 +483,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 +506,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 88a2d5e1c..cda137c5a 100644 --- a/src/plopp/core/utils.py +++ b/src/plopp/core/utils.py @@ -31,6 +31,11 @@ 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 + # Scipp does not support computing midpoints of variables with variances. The + # variances describe the input coordinate points and cannot in general be mapped + # to inferred bin edges, so only use the values for this conversion. + 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 bd02d11d7..8064a6f4a 100644 --- a/src/plopp/plotting/_inspector.py +++ b/src/plopp/plotting/_inspector.py @@ -146,6 +146,7 @@ def inspector( continuous_update: bool = True, coords: list[str] | None = None, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -248,6 +249,8 @@ def inspector( 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). + errorbars_x: + Whether to add error bars from coordinate variances to the line (1d figure). figsize: The width and height of the figure, in inches. grid: @@ -317,6 +320,7 @@ def inspector( f1d = linefigure( autoscale=autoscale, errorbars=errorbars, + errorbars_x=errorbars_x, grid=grid, legend=legend, mask_color=mask_color, diff --git a/src/plopp/plotting/_plot.py b/src/plopp/plotting/_plot.py index 5ce303515..b93b9870e 100644 --- a/src/plopp/plotting/_plot.py +++ b/src/plopp/plotting/_plot.py @@ -28,6 +28,7 @@ def plot( cmin: sc.Variable | float | None = None, coords: list[str] | None = None, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, figsize: tuple[float, float] | None = None, grid: bool = False, ignore_size: bool = False, @@ -76,6 +77,8 @@ def plot( 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'. + errorbars_x: + Whether to add error bars from coordinate variances to the line. figsize: The width and height of the figure, in inches. grid: @@ -142,6 +145,7 @@ def plot( cmax=cmax, cmin=cmin, errorbars=errorbars, + errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/_slicer.py b/src/plopp/plotting/_slicer.py index 01ca6d5ed..9115a655d 100644 --- a/src/plopp/plotting/_slicer.py +++ b/src/plopp/plotting/_slicer.py @@ -258,6 +258,7 @@ def slicer( coords: list[str] | None = None, enable_player: bool = False, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -318,6 +319,8 @@ def slicer( 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'. + errorbars_x: + Whether to add error bars from coordinate variances to the line. figsize: The width and height of the figure, in inches. grid: @@ -389,6 +392,7 @@ def slicer( coords=coords, enable_player=enable_player, errorbars=errorbars, + errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/_superplot.py b/src/plopp/plotting/_superplot.py index 902cfc145..da310e35a 100644 --- a/src/plopp/plotting/_superplot.py +++ b/src/plopp/plotting/_superplot.py @@ -19,6 +19,7 @@ def superplot( coords: list[str] | None = None, enable_player: bool = False, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -64,6 +65,8 @@ def superplot( 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'. + errorbars_x: + Whether to add error bars from coordinate variances to the line. figsize: The width and height of the figure, in inches. grid: @@ -119,6 +122,7 @@ def superplot( coords=coords, enable_player=enable_player, errorbars=errorbars, + errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/_xyplot.py b/src/plopp/plotting/_xyplot.py index 54f24bcf2..35c549b33 100644 --- a/src/plopp/plotting/_xyplot.py +++ b/src/plopp/plotting/_xyplot.py @@ -43,6 +43,7 @@ def xyplot( aspect: Literal['auto', 'equal'] | None = None, autoscale: bool = True, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -80,6 +81,8 @@ def xyplot( 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'. + errorbars_x: + Whether to add error bars from ``x`` variances to the line. figsize: The width and height of the figure, in inches. grid: @@ -125,6 +128,7 @@ def xyplot( aspect=aspect, autoscale=autoscale, errorbars=errorbars, + errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/common.py b/src/plopp/plotting/common.py index aab49e7d6..a916213b8 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) @@ -360,6 +362,7 @@ def categorize_args( cmax: sc.Variable | float | None = None, cmin: sc.Variable | float | None = None, errorbars: Literal['band', 'bar', True, False] = True, + errorbars_x: bool = False, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -404,7 +407,12 @@ def categorize_args( **kwargs, } return { - "1d": {'errorbars': errorbars, 'legend': legend, **common_args}, + "1d": { + 'errorbars': errorbars, + 'errorbars_x': errorbars_x, + 'legend': legend, + **common_args, + }, "2d": { 'cbar': cbar, 'cmap': cmap, diff --git a/tests/backends/matplotlib/mpl_line_test.py b/tests/backends/matplotlib/mpl_line_test.py index 5f9e31e7f..fbc41612c 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,6 +14,12 @@ 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') line = Line(canvas=Canvas(), data=da) @@ -71,6 +78,85 @@ def test_line_hide_errorbars(): assert line._error is None +def test_coordinate_errorbars_are_disabled_by_default(): + da = _with_coord_variances(data_array(ndim=1)) + line = Line(canvas=Canvas(), data=da) + assert line._error_x is None + + +def test_coordinate_errorbars_reject_non_boolean_mode(): + da = _with_coord_variances(data_array(ndim=1)) + with pytest.raises(TypeError, match="errorbars_x must be True or False"): + Line(canvas=Canvas(), data=da, errorbars_x='band') + + +def test_line_with_coordinate_errorbars(): + da = _with_coord_variances(data_array(ndim=1)) + line = Line(canvas=Canvas(), data=da, errorbars_x=True) + + 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_with_bin_edges_and_coordinate_errorbars(): + da = _with_coord_variances(data_array(ndim=1, binedges=True)) + line = Line(canvas=Canvas(), data=da, errorbars_x=True) + + 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() + ) + + +@pytest.mark.parametrize('mode', ['bar', 'band']) +def test_line_with_data_and_coordinate_errorbars(mode): + da = _with_coord_variances(data_array(ndim=1, variances=True)) + line = Line(canvas=Canvas(), data=da, errorbars=mode, errorbars_x=True) + assert line._error is not None + assert line._error_x is not None + + +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_x=True) + 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_x=True) + 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_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_x=True) + 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 +218,22 @@ def test_line_update_with_errorbars(mode): ) +def test_line_update_adds_and_removes_coordinate_errorbars(): + da = data_array(ndim=1) + line = Line(canvas=Canvas(), data=da, errorbars_x=True) + assert line._error_x is None + + line.visible = False + with_variances = _with_coord_variances(da.copy(deep=True)) + line.update(with_variances) + assert line._error_x is not None + assert not line._error_x.get_visible() + + line.update(da) + assert line._error_x is None + line.bbox(xscale='linear', yscale='linear') + + @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 2b397919a..220e23616 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 bf4e46d76..4c4cf838b 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(): + 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, errorbars_x=True) + 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 19d4fe2ee..6204dc1fd 100644 --- a/tests/plotting/plot_1d_test.py +++ b/tests/plotting/plot_1d_test.py @@ -449,6 +449,15 @@ def test_plot_1d_data_with_errorbars_auto(): assert p.canvas.ymax > 1.0 +@pytest.mark.parametrize('enabled', [False, True]) +def test_plot_1d_data_with_coordinate_errorbars(enabled): + da = data_array(ndim=1) + da.coords[da.dim].variances = np.full(da.sizes[da.dim], 0.25) + p = da.plot(errorbars_x=enabled) + [line] = p.artists.values() + assert (line._error_x is not None) is enabled + + @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 72d8267d7..eed1cb65a 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(self): + da = data_array(ndim=2) + da.coords['xx'].variances = np.full(da.sizes['xx'], 0.25) + sp = SlicerPlot(da, keep=['xx'], mode='single', errorbars_x=True) + [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 b928ec862..461be9e96 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(): + da = data_array(ndim=2) + da.coords['xx'].variances = da.coords['xx'].values * 0.0 + 0.25 + sp = superplot(da, keep='xx', errorbars_x=True) + [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 a4ff31afb..1d89a48b3 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(): + 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, errorbars_x=True) + [line] = fig.artists.values() + assert line._error_x is not None + + def test_xyplot_ndarray(): N = 50 x = np.arange(float(N)) From bcce56757e22c60bf1efac9372865081337704eb Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 10 Aug 2026 08:46:11 +0200 Subject: [PATCH 2/6] fix: preserve comment --- src/plopp/backends/matplotlib/line.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py index 762829352..32b1f172a 100644 --- a/src/plopp/backends/matplotlib/line.py +++ b/src/plopp/backends/matplotlib/line.py @@ -119,6 +119,8 @@ def update(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, hist: bool) -> Non 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 and self._axis == 'y': x = 0.5 * (x[1:] + x[:-1]) # Use bin centers for bars From 159760293ea376c401e90ccb98f38709075f2abf Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 10 Aug 2026 08:54:19 +0200 Subject: [PATCH 3/6] fix --- src/plopp/backends/common.py | 6 ++---- src/plopp/backends/matplotlib/line.py | 10 +++------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/plopp/backends/common.py b/src/plopp/backends/common.py index d2925dd0f..144db66e5 100644 --- a/src/plopp/backends/common.py +++ b/src/plopp/backends/common.py @@ -31,7 +31,7 @@ def check_ndim(data: sc.DataArray, ndim: int, origin: str) -> None: ) -def make_line_data(data: sc.DataArray, dim: str, errorbars_x: bool = False) -> dict: +def make_line_data(data: sc.DataArray, dim: str) -> dict: """ Prepare data for plotting a line. This includes extracting the x and y values, and optionally the error bars and masks @@ -45,8 +45,6 @@ def make_line_data(data: sc.DataArray, dim: str, errorbars_x: bool = False) -> d The data array to extract values from. dim: The dimension along which to extract values. - errorbars_x: - Whether to extract coordinate standard deviations. """ x = data.coords[dim] y = data.data @@ -69,7 +67,7 @@ def make_line_data(data: sc.DataArray, dim: str, errorbars_x: bool = False) -> d if hist: for array in (values, mask): array['y'] = np.concatenate([array['y'][0:1], array['y']]) - if errorbars_x and x.variances is not None: + if x.variances is not None: error_x = { 'x': xvalues, 'y': values['y'], diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py index 32b1f172a..53ed3b859 100644 --- a/src/plopp/backends/matplotlib/line.py +++ b/src/plopp/backends/matplotlib/line.py @@ -275,9 +275,7 @@ def __init__( if key in line_args: line_args[alias] = line_args.pop(key) - line_data = make_line_data( - data=self._data, dim=self._dim, errorbars_x=self._errorbars_x - ) + line_data = make_line_data(data=self._data, dim=self._dim) default_step_style = { 'linestyle': 'solid', @@ -353,7 +351,7 @@ def _make_errorbar(self, *, mode, axis, data, hist): def _sync_errorbars_x(self, line_data): data = line_data['stddevs_x'] - if data is None: + if not self._errorbars_x or data is None: if self._error_x is not None: self._error_x.remove() self._error_x = None @@ -378,9 +376,7 @@ def update(self, new_values: sc.DataArray): """ check_ndim(new_values, ndim=1, origin='Line') self._data = new_values - line_data = make_line_data( - data=self._data, dim=self._dim, errorbars_x=self._errorbars_x - ) + line_data = make_line_data(data=self._data, dim=self._dim) self._line.set_data(line_data['values']['x'], line_data['values']['y']) self._mask.set_data(line_data['mask']['x'], line_data['mask']['y']) From 262cf86e4417d02e595077141a03db903eef8ec4 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 10 Aug 2026 08:58:09 +0200 Subject: [PATCH 4/6] fix --- src/plopp/core/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/plopp/core/utils.py b/src/plopp/core/utils.py index cda137c5a..e3323dd4a 100644 --- a/src/plopp/core/utils.py +++ b/src/plopp/core/utils.py @@ -31,9 +31,6 @@ 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 - # Scipp does not support computing midpoints of variables with variances. The - # variances describe the input coordinate points and cannot in general be mapped - # to inferred bin edges, so only use the values for this conversion. if x.variances is not None: x = sc.values(x) if x.dtype in ('int32', 'int64'): From b10aa86409ebb74424481dbdde7636a8a58a6ca8 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Tue, 11 Aug 2026 16:32:20 +0200 Subject: [PATCH 5/6] fix: make errorbar_x=True default, and disable horizontal error bars for bin-edge data --- src/plopp/backends/common.py | 4 +- src/plopp/backends/matplotlib/line.py | 13 +++---- src/plopp/plotting/_inspector.py | 3 +- src/plopp/plotting/_plot.py | 5 ++- src/plopp/plotting/_slicer.py | 5 ++- src/plopp/plotting/_superplot.py | 5 ++- src/plopp/plotting/_xyplot.py | 5 ++- src/plopp/plotting/common.py | 2 +- tests/backends/matplotlib/mpl_line_test.py | 45 ++++++++++++++++------ tests/plotting/inspector_test.py | 4 +- tests/plotting/plot_1d_test.py | 8 ++++ tests/plotting/slicer_test.py | 4 +- tests/plotting/superplot_test.py | 4 +- tests/plotting/xyplot_test.py | 6 ++- 14 files changed, 74 insertions(+), 39 deletions(-) diff --git a/src/plopp/backends/common.py b/src/plopp/backends/common.py index 144db66e5..48ee51a66 100644 --- a/src/plopp/backends/common.py +++ b/src/plopp/backends/common.py @@ -67,10 +67,10 @@ 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']]) - if x.variances is not None: + if not hist and x.variances is not None: error_x = { 'x': xvalues, - 'y': values['y'], + 'y': yvalues, 'e': np.asarray(sc.stddevs(x).values), } return { diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py index 53ed3b859..5c6b3550c 100644 --- a/src/plopp/backends/matplotlib/line.py +++ b/src/plopp/backends/matplotlib/line.py @@ -63,7 +63,7 @@ def __init__( ax, x, y, e, color=color, zorder=zorder, alpha=alpha, hist=hist ) elif self._mode == ErrorbarMode.bar: - if hist and self._axis == 'y': + 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) @@ -122,7 +122,7 @@ def update(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, hist: bool) -> Non # 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 and self._axis == 'y': + if hist: x = 0.5 * (x[1:] + x[:-1]) # Use bin centers for bars if self._axis == 'x': lower = np.column_stack((x - e, y)) @@ -229,7 +229,8 @@ class Line: 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'. errorbars_x: - Whether to add error bars from coordinate variances to the line. + Whether to add error bars from coordinate variances to the line. Ignored for + bin-edge coordinates. mask_color: The color of the masked points. """ @@ -241,7 +242,7 @@ def __init__( uid: str | None = None, artist_number: int = 0, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = True, mask_color: str | None = None, **kwargs, ): @@ -258,15 +259,11 @@ def __init__( line_args = parse_dicts_in_kwargs(kwargs, name=data.name) - self._line = None - self._mask = None self._error = None self._error_x = None - self._unit = 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' diff --git a/src/plopp/plotting/_inspector.py b/src/plopp/plotting/_inspector.py index 8064a6f4a..9fd4ae006 100644 --- a/src/plopp/plotting/_inspector.py +++ b/src/plopp/plotting/_inspector.py @@ -146,7 +146,7 @@ def inspector( continuous_update: bool = True, coords: list[str] | None = None, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -251,6 +251,7 @@ def inspector( specify the error bar style. Valid values are 'band' and 'bar' (1d figure). errorbars_x: Whether to add error bars from coordinate variances to the line (1d figure). + 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 b93b9870e..4d7444eb8 100644 --- a/src/plopp/plotting/_plot.py +++ b/src/plopp/plotting/_plot.py @@ -28,7 +28,7 @@ def plot( cmin: sc.Variable | float | None = None, coords: list[str] | None = None, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = True, figsize: tuple[float, float] | None = None, grid: bool = False, ignore_size: bool = False, @@ -78,7 +78,8 @@ def plot( 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'. errorbars_x: - Whether to add error bars from coordinate variances to the line. + Whether to add error bars from coordinate variances to the line. 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 9115a655d..418e331e6 100644 --- a/src/plopp/plotting/_slicer.py +++ b/src/plopp/plotting/_slicer.py @@ -258,7 +258,7 @@ def slicer( coords: list[str] | None = None, enable_player: bool = False, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -320,7 +320,8 @@ def slicer( 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'. errorbars_x: - Whether to add error bars from coordinate variances to the line. + Whether to add error bars from coordinate variances to the line. 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 da310e35a..c8fcd51df 100644 --- a/src/plopp/plotting/_superplot.py +++ b/src/plopp/plotting/_superplot.py @@ -19,7 +19,7 @@ def superplot( coords: list[str] | None = None, enable_player: bool = False, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -66,7 +66,8 @@ def superplot( 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'. errorbars_x: - Whether to add error bars from coordinate variances to the line. + Whether to add error bars from coordinate variances to the line. 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 35c549b33..a0394a9c3 100644 --- a/src/plopp/plotting/_xyplot.py +++ b/src/plopp/plotting/_xyplot.py @@ -43,7 +43,7 @@ def xyplot( aspect: Literal['auto', 'equal'] | None = None, autoscale: bool = True, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = True, figsize: tuple[float, float] | None = None, grid: bool = False, legend: bool | tuple[float, float] = True, @@ -82,7 +82,8 @@ def xyplot( 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'. errorbars_x: - Whether to add error bars from ``x`` variances to the line. + Whether to add error bars from ``x`` variances to the line. 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 1f4aa853f..2d5b96bf0 100644 --- a/src/plopp/plotting/common.py +++ b/src/plopp/plotting/common.py @@ -362,7 +362,7 @@ def categorize_args( cmax: sc.Variable | float | None = None, cmin: sc.Variable | float | None = None, errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = False, + errorbars_x: bool = 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 fbc41612c..2880eeade 100644 --- a/tests/backends/matplotlib/mpl_line_test.py +++ b/tests/backends/matplotlib/mpl_line_test.py @@ -78,10 +78,10 @@ def test_line_hide_errorbars(): assert line._error is None -def test_coordinate_errorbars_are_disabled_by_default(): +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 None + assert line._error_x is not None def test_coordinate_errorbars_reject_non_boolean_mode(): @@ -103,17 +103,10 @@ def test_line_with_coordinate_errorbars(): ) -def test_line_with_bin_edges_and_coordinate_errorbars(): +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_x=True) - - 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() - ) + assert line._error_x is None @pytest.mark.parametrize('mode', ['bar', 'band']) @@ -218,6 +211,16 @@ 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()) + + def test_line_update_adds_and_removes_coordinate_errorbars(): da = data_array(ndim=1) line = Line(canvas=Canvas(), data=da, errorbars_x=True) @@ -234,6 +237,26 @@ def test_line_update_adds_and_removes_coordinate_errorbars(): line.bbox(xscale='linear', yscale='linear') +def test_line_update_does_not_add_coordinate_errorbars_when_disabled(): + da = data_array(ndim=1) + line = Line(canvas=Canvas(), data=da, errorbars_x=False) + line.update(_with_coord_variances(da.copy(deep=True))) + assert line._error_x is None + + +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/plotting/inspector_test.py b/tests/plotting/inspector_test.py index 4c4cf838b..118dbea6a 100644 --- a/tests/plotting/inspector_test.py +++ b/tests/plotting/inspector_test.py @@ -65,11 +65,11 @@ def test_line_creation(): @pytest.mark.usefixtures('_use_ipympl') -def test_line_with_coordinate_errorbars(): +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, errorbars_x=True) + ip = pp.inspector(da, dim=dim) fig2d = ip[0][0] fig1d = ip[0][1] fig2d.toolbar['inspect'].value = True diff --git a/tests/plotting/plot_1d_test.py b/tests/plotting/plot_1d_test.py index 6204dc1fd..638e19bad 100644 --- a/tests/plotting/plot_1d_test.py +++ b/tests/plotting/plot_1d_test.py @@ -458,6 +458,14 @@ def test_plot_1d_data_with_coordinate_errorbars(enabled): 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 eed1cb65a..72a1c3a88 100644 --- a/tests/plotting/slicer_test.py +++ b/tests/plotting/slicer_test.py @@ -113,10 +113,10 @@ def test_no_keep_with_figure(self): sp = SlicerPlot(da) assert 'yy' in sp.slicer.slider.controls - def test_with_coordinate_errorbars(self): + 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', errorbars_x=True) + sp = SlicerPlot(da, keep=['xx'], mode='single') [line] = sp.figure.artists.values() assert line._error_x is not None diff --git a/tests/plotting/superplot_test.py b/tests/plotting/superplot_test.py index 461be9e96..46f55b8de 100644 --- a/tests/plotting/superplot_test.py +++ b/tests/plotting/superplot_test.py @@ -15,10 +15,10 @@ def test_creation(): assert len(sp.right_bar[0]._lines) == 0 -def test_coordinate_errorbars(): +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', errorbars_x=True) + sp = superplot(da, keep='xx') [line] = sp.artists.values() assert line._error_x is not None diff --git a/tests/plotting/xyplot_test.py b/tests/plotting/xyplot_test.py index 1d89a48b3..a34520a9c 100644 --- a/tests/plotting/xyplot_test.py +++ b/tests/plotting/xyplot_test.py @@ -27,11 +27,11 @@ def test_xyplot_woth_variances(): assert line._error is not None -def test_xyplot_with_x_variances(): +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, errorbars_x=True) + fig = pp.xyplot(x, y) [line] = fig.artists.values() assert line._error_x is not None @@ -84,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(): From acf3ae58bf9e5a03e40441ee341b3855aa3ac4c2 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 14 Aug 2026 14:59:30 +0200 Subject: [PATCH 6/6] fix: change interface to suggested, remove errorbar_x --- src/plopp/backends/matplotlib/line.py | 126 +++++++++++---------- src/plopp/plotting/_inspector.py | 14 +-- src/plopp/plotting/_plot.py | 12 +- src/plopp/plotting/_slicer.py | 12 +- src/plopp/plotting/_superplot.py | 12 +- src/plopp/plotting/_xyplot.py | 12 +- src/plopp/plotting/common.py | 10 +- tests/backends/matplotlib/mpl_line_test.py | 89 +++++++++++---- tests/plotting/plot_1d_test.py | 16 ++- 9 files changed, 171 insertions(+), 132 deletions(-) diff --git a/src/plopp/backends/matplotlib/line.py b/src/plopp/backends/matplotlib/line.py index 5c6b3550c..7554e949d 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,8 +62,8 @@ class Errorbars: def __init__( self, - mode: Literal["band", "bar"], - axis: Literal['x', 'y'], + mode: ErrorbarStyle, + axis: ErrorbarAxis, ax: Axes, x: np.ndarray, y: np.ndarray, @@ -53,16 +73,16 @@ 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. @@ -80,7 +100,7 @@ def __init__( raise ValueError(f"Invalid errorbar mode: {mode}") def update(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, hist: bool) -> None: - if self._mode == ErrorbarMode.band: + if self._mode == 'band': yme = y - e ype = y + e verts = self._artist.get_paths()[0].vertices @@ -144,65 +164,65 @@ 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._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._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._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._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: 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: return np.array(self._barline_collection.get_segments())[:, :, 1] @@ -226,10 +246,10 @@ 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'. - errorbars_x: - Whether to add error bars from coordinate variances to the line. Ignored for + 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. @@ -241,8 +261,7 @@ def __init__( data: sc.DataArray, uid: str | None = None, artist_number: int = 0, - errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = True, + errorbars: ErrorbarSetting = True, mask_color: str | None = None, **kwargs, ): @@ -251,11 +270,7 @@ def __init__( self._canvas = canvas self._ax = self._canvas.ax self._data = data - if errorbars is True: - errorbars = 'bar' - if not isinstance(errorbars_x, bool): - raise TypeError("errorbars_x must be True or False") - self._errorbars_x = errorbars_x + self._errorbar_modes = _parse_errorbar_setting(errorbars) line_args = parse_dicts_in_kwargs(kwargs, name=data.name) @@ -263,7 +278,6 @@ def __init__( self._error_x = None self.label = data.name self._dim = self._data.dim - self._unit = self._data.unit if mask_color is None: mask_color = 'black' @@ -323,14 +337,7 @@ def __init__( lw=self._line.get_linewidth() * 3, zorder=self._line.get_zorder() - 1 ) - if errorbars and (line_data['stddevs'] is not None): - self._error = self._make_errorbar( - mode=errorbars, - axis='y', - data=line_data['stddevs'], - hist=line_data['hist'], - ) - self._sync_errorbars_x(line_data) + self._sync_errorbars(line_data) def _make_errorbar(self, *, mode, axis, data, hist): return Errorbars( @@ -346,21 +353,27 @@ def _make_errorbar(self, *, mode, axis, data, hist): hist=hist, ) - def _sync_errorbars_x(self, line_data): - data = line_data['stddevs_x'] - if not self._errorbars_x or data is None: - if self._error_x is not None: - self._error_x.remove() - self._error_x = None - elif self._error_x is None: - self._error_x = self._make_errorbar( - mode='bar', axis='x', data=data, hist=line_data['hist'] - ) - self._error_x.set_visible(self.visible) + 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: - self._error_x.update( - x=data['x'], y=data['y'], e=data['e'], hist=line_data['hist'] - ) + 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): """ @@ -379,14 +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_x(line_data) + self._sync_errorbars(line_data) def remove(self): """ diff --git a/src/plopp/plotting/_inspector.py b/src/plopp/plotting/_inspector.py index 9fd4ae006..1f8fe0cb0 100644 --- a/src/plopp/plotting/_inspector.py +++ b/src/plopp/plotting/_inspector.py @@ -145,8 +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_x: bool = 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, @@ -247,11 +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). - errorbars_x: - Whether to add error bars from coordinate variances to the line (1d figure). - Ignored for bin-edge coordinates. + 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: @@ -321,7 +320,6 @@ def inspector( f1d = linefigure( autoscale=autoscale, errorbars=errorbars, - errorbars_x=errorbars_x, grid=grid, legend=legend, mask_color=mask_color, diff --git a/src/plopp/plotting/_plot.py b/src/plopp/plotting/_plot.py index 4d7444eb8..7f171e110 100644 --- a/src/plopp/plotting/_plot.py +++ b/src/plopp/plotting/_plot.py @@ -27,8 +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_x: bool = True, + errorbars: Literal['band', 'bar', 'xonly', 'yonly', True, False] = True, figsize: tuple[float, float] | None = None, grid: bool = False, ignore_size: bool = False, @@ -75,10 +74,10 @@ 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'. - errorbars_x: - Whether to add error bars from coordinate variances to the line. Ignored for + 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. @@ -146,7 +145,6 @@ def plot( cmax=cmax, cmin=cmin, errorbars=errorbars, - errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/_slicer.py b/src/plopp/plotting/_slicer.py index 418e331e6..0c16594ae 100644 --- a/src/plopp/plotting/_slicer.py +++ b/src/plopp/plotting/_slicer.py @@ -257,8 +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_x: bool = 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, @@ -317,10 +316,10 @@ 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'. - errorbars_x: - Whether to add error bars from coordinate variances to the line. Ignored for + 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. @@ -393,7 +392,6 @@ def slicer( coords=coords, enable_player=enable_player, errorbars=errorbars, - errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/_superplot.py b/src/plopp/plotting/_superplot.py index c8fcd51df..f65372812 100644 --- a/src/plopp/plotting/_superplot.py +++ b/src/plopp/plotting/_superplot.py @@ -18,8 +18,7 @@ def superplot( autoscale: bool = True, coords: list[str] | None = None, enable_player: bool = False, - errorbars: Literal['band', 'bar', True, False] = True, - errorbars_x: bool = 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, @@ -63,10 +62,10 @@ 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'. - errorbars_x: - Whether to add error bars from coordinate variances to the line. Ignored for + 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. @@ -123,7 +122,6 @@ def superplot( coords=coords, enable_player=enable_player, errorbars=errorbars, - errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/_xyplot.py b/src/plopp/plotting/_xyplot.py index a0394a9c3..da6cf0089 100644 --- a/src/plopp/plotting/_xyplot.py +++ b/src/plopp/plotting/_xyplot.py @@ -42,8 +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_x: bool = 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, @@ -79,10 +78,10 @@ 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'. - errorbars_x: - Whether to add error bars from ``x`` variances to the line. Ignored when ``x`` + 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. @@ -129,7 +128,6 @@ def xyplot( aspect=aspect, autoscale=autoscale, errorbars=errorbars, - errorbars_x=errorbars_x, figsize=figsize, grid=grid, legend=legend, diff --git a/src/plopp/plotting/common.py b/src/plopp/plotting/common.py index 2d5b96bf0..59d8e44a6 100644 --- a/src/plopp/plotting/common.py +++ b/src/plopp/plotting/common.py @@ -361,8 +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_x: bool = 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, @@ -407,12 +406,7 @@ def categorize_args( **kwargs, } return { - "1d": { - 'errorbars': errorbars, - 'errorbars_x': errorbars_x, - 'legend': legend, - **common_args, - }, + "1d": {'errorbars': errorbars, 'legend': legend, **common_args}, "2d": { 'cbar': cbar, 'cmap': cmap, diff --git a/tests/backends/matplotlib/mpl_line_test.py b/tests/backends/matplotlib/mpl_line_test.py index 2880eeade..d4dc7dcef 100644 --- a/tests/backends/matplotlib/mpl_line_test.py +++ b/tests/backends/matplotlib/mpl_line_test.py @@ -21,9 +21,8 @@ def _with_coord_variances(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) @@ -84,15 +83,15 @@ def test_coordinate_errorbars_are_enabled_by_default(): assert line._error_x is not None -def test_coordinate_errorbars_reject_non_boolean_mode(): +def test_invalid_errorbar_setting_raises(): da = _with_coord_variances(data_array(ndim=1)) - with pytest.raises(TypeError, match="errorbars_x must be True or False"): - Line(canvas=Canvas(), data=da, errorbars_x='band') + 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_x=True) + line = Line(canvas=Canvas(), data=da, errorbars='xonly') coord = da.coords[da.dim] assert np.allclose( @@ -105,22 +104,36 @@ def test_line_with_coordinate_errorbars(): 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_x=True) + line = Line(canvas=Canvas(), data=da, errorbars='xonly') assert line._error_x is None -@pytest.mark.parametrize('mode', ['bar', 'band']) -def test_line_with_data_and_coordinate_errorbars(mode): +@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=mode, errorbars_x=True) - assert line._error is not None - assert line._error_x is not None + 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_x=True) + 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]]) @@ -131,7 +144,7 @@ def test_line_update_coordinate_errorbars_with_caps(): 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_x=True) + 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] @@ -141,10 +154,26 @@ def test_line_update_coordinate_errorbars_with_caps(): 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_x=True) + 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 @@ -221,27 +250,37 @@ def test_line_update_with_bin_edges_and_errorbars(): assert np.allclose(line._error.get_xdata().max(), x.max()) -def test_line_update_adds_and_removes_coordinate_errorbars(): +@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_x=True) + 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(da.copy(deep=True)) + with_variances = _with_coord_variances(data_array(ndim=1, variances=True)) line.update(with_variances) - assert line._error_x is not None - assert not line._error_x.get_visible() + 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_line_update_does_not_add_coordinate_errorbars_when_disabled(): - da = data_array(ndim=1) - line = Line(canvas=Canvas(), data=da, errorbars_x=False) - line.update(_with_coord_variances(da.copy(deep=True))) - assert line._error_x is None +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(): diff --git a/tests/plotting/plot_1d_test.py b/tests/plotting/plot_1d_test.py index 638e19bad..78fc82d13 100644 --- a/tests/plotting/plot_1d_test.py +++ b/tests/plotting/plot_1d_test.py @@ -449,11 +449,21 @@ def test_plot_1d_data_with_errorbars_auto(): assert p.canvas.ymax > 1.0 -@pytest.mark.parametrize('enabled', [False, True]) -def test_plot_1d_data_with_coordinate_errorbars(enabled): +@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_x=enabled) + p = da.plot(errorbars=setting) [line] = p.artists.values() assert (line._error_x is not None) is enabled