Skip to content
24 changes: 23 additions & 1 deletion src/plopp/backends/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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 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:
Expand All @@ -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.
Expand Down
136 changes: 98 additions & 38 deletions src/plopp/backends/matplotlib/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Errorbars:
def __init__(
self,
mode: Literal["band", "bar"],
axis: Literal['x', 'y'],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the interface here strange, with both mode and axis args.
See other comment about having xonly and ylonly errorbar modes.
I would vote for always showing horizontal errorbars if present by default.

ax: Axes,
x: np.ndarray,
y: np.ndarray,
Expand All @@ -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,
Comment thread
jokasimr marked this conversation as resolved.
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
Expand Down Expand Up @@ -105,16 +115,30 @@ 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:
# 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
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()
Expand All @@ -123,7 +147,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:
Expand All @@ -136,7 +160,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:
Expand All @@ -149,7 +173,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:
Expand All @@ -162,7 +186,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:
Expand All @@ -175,15 +199,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:
Expand All @@ -206,6 +228,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.
"""
Expand All @@ -217,6 +241,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,
):
Expand All @@ -227,12 +252,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
Expand Down Expand Up @@ -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 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)
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):
"""
Expand All @@ -335,15 +389,17 @@ 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):
"""
Remove the line, masks and errorbar artists from the canvas.
"""
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
Expand All @@ -356,8 +412,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
Expand Down Expand Up @@ -408,8 +465,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
Expand All @@ -423,8 +481,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(
Expand All @@ -445,6 +504,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,
)
2 changes: 2 additions & 0 deletions src/plopp/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/plopp/plotting/_inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -317,6 +320,7 @@ def inspector(
f1d = linefigure(
autoscale=autoscale,
errorbars=errorbars,
errorbars_x=errorbars_x,
grid=grid,
legend=legend,
mask_color=mask_color,
Expand Down
Loading
Loading