mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-06 16:40:56 +02:00
perf(waveform): hide curve symbols above 1000 points
This commit is contained in:
@@ -40,6 +40,13 @@ class CurveConfig(ConnectionConfig):
|
||||
None, description="The color of the symbol of the curve."
|
||||
)
|
||||
symbol_size: int | None = Field(7, description="The size of the symbol of the curve.")
|
||||
symbol_point_limit: int | None = Field(
|
||||
1000,
|
||||
description=(
|
||||
"Hide the symbol once the curve holds more than this many points. "
|
||||
"None keeps the symbol at every data size."
|
||||
),
|
||||
)
|
||||
pen_width: int | None = Field(4, description="The width of the pen of the curve.")
|
||||
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
|
||||
"solid", description="The style of the pen of the curve."
|
||||
@@ -63,6 +70,45 @@ class CurveConfig(ConnectionConfig):
|
||||
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
|
||||
|
||||
|
||||
def _incoming_length(args: tuple, kwargs: dict) -> int | None:
|
||||
"""
|
||||
Number of points a ``PlotDataItem.setData`` call is about to plot.
|
||||
|
||||
``setData`` accepts several shapes -- ``(y)``, ``(x, y)``, keyword ``x``/``y``,
|
||||
a dict, a record array or a list of dicts. Only the plain sequence forms are
|
||||
resolved here; anything else returns None so the caller leaves the symbol as
|
||||
it is rather than guessing.
|
||||
|
||||
Args:
|
||||
args(tuple): Positional arguments passed to ``setData``.
|
||||
kwargs(dict): Keyword arguments passed to ``setData``.
|
||||
|
||||
Returns:
|
||||
int | None: The point count, or None if it cannot be determined cheaply.
|
||||
"""
|
||||
candidate = None
|
||||
if len(args) >= 2:
|
||||
candidate = args[1]
|
||||
elif len(args) == 1:
|
||||
candidate = args[0]
|
||||
elif "y" in kwargs:
|
||||
candidate = kwargs["y"]
|
||||
elif "x" in kwargs:
|
||||
candidate = kwargs["x"]
|
||||
elif not args and not kwargs:
|
||||
# setData() with no arguments clears the curve
|
||||
return 0
|
||||
|
||||
if isinstance(candidate, np.ndarray):
|
||||
return candidate.shape[0] if candidate.ndim else None
|
||||
if isinstance(candidate, (list, tuple)):
|
||||
# a list of dicts is a spot-style record list, not a value column
|
||||
if candidate and isinstance(candidate[0], dict):
|
||||
return None
|
||||
return len(candidate)
|
||||
return None
|
||||
|
||||
|
||||
class Curve(BECConnector, pg.PlotDataItem):
|
||||
USER_ACCESS = [
|
||||
"remove",
|
||||
@@ -93,6 +139,8 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
parent_item: Waveform | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
# Set before apply_config(), which consults it to honour the symbol limit.
|
||||
self._symbols_suppressed = False
|
||||
if config is None:
|
||||
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
|
||||
self.config = config
|
||||
@@ -145,6 +193,43 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
|
||||
self.setSymbolBrush(brush)
|
||||
self.setSymbolSize(self.config.symbol_size)
|
||||
# A dense curve keeps its symbol hidden; the config still records what
|
||||
# to restore once the data drops back below the limit.
|
||||
self.setSymbol(None if self._symbols_suppressed else self.config.symbol)
|
||||
|
||||
def setData(self, *args, **kwargs):
|
||||
"""
|
||||
Set the curve data, hiding the symbol for datasets above the configured limit.
|
||||
|
||||
pyqtgraph draws symbols through ``ScatterPlotItem``, which builds a style
|
||||
tuple per point in Python (``SymbolAtlas._keys``). That is linear in the
|
||||
number of points and dominates everything else: at 50k points a ``setData``
|
||||
costs ~90 ms with a symbol and ~0.5 ms without. Scans routinely exceed the
|
||||
limit, so the symbol is dropped there and restored when the data shrinks.
|
||||
"""
|
||||
# getattr guards against setData being reached before __init__ sets the field.
|
||||
self._data_version = getattr(self, "_data_version", 0) + 1
|
||||
self._apply_symbol_limit(_incoming_length(args, kwargs))
|
||||
super().setData(*args, **kwargs)
|
||||
|
||||
def _apply_symbol_limit(self, data_length: int | None) -> None:
|
||||
"""
|
||||
Hide or restore the symbol for the given incoming data length.
|
||||
|
||||
Args:
|
||||
data_length(int | None): Length of the data about to be set, or None
|
||||
when it could not be determined (the limit is then left alone).
|
||||
"""
|
||||
limit = self.config.symbol_point_limit
|
||||
if data_length is None or limit is None:
|
||||
return
|
||||
suppressed = data_length > limit
|
||||
if suppressed == self._symbols_suppressed:
|
||||
return
|
||||
self._symbols_suppressed = suppressed
|
||||
if suppressed:
|
||||
self.setSymbol(None)
|
||||
elif self.config.symbol:
|
||||
self.setSymbol(self.config.symbol)
|
||||
|
||||
@property
|
||||
@@ -201,12 +286,6 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
"""Monotonic counter bumped on every ``setData`` call."""
|
||||
return self._data_version
|
||||
|
||||
def setData(self, *args, **kwargs):
|
||||
"""Wrap ``PlotDataItem.setData`` to track how often the data changes."""
|
||||
# getattr guards against setData being reached before __init__ sets the field.
|
||||
self._data_version = getattr(self, "_data_version", 0) + 1
|
||||
super().setData(*args, **kwargs)
|
||||
|
||||
def set_data(self, x: list | np.ndarray, y: list | np.ndarray):
|
||||
"""
|
||||
Set the data of the curve.
|
||||
@@ -276,8 +355,11 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
symbol(str): Symbol of the curve.
|
||||
"""
|
||||
self.config.symbol = symbol
|
||||
self.setSymbol(symbol)
|
||||
self.updateItems()
|
||||
# While the curve is dense the symbol stays hidden; the config keeps the
|
||||
# request so it takes effect once the data drops below the limit.
|
||||
if not self._symbols_suppressed:
|
||||
self.setSymbol(symbol)
|
||||
self.updateItems()
|
||||
|
||||
def set_symbol_color(self, symbol_color: str):
|
||||
"""
|
||||
|
||||
@@ -2349,10 +2349,12 @@ class Waveform(PlotBase):
|
||||
) -> None:
|
||||
"""
|
||||
Based on the length of the data this method will adjust the plotting settings of
|
||||
Curve items, by deactivating the symbol and activating downsampling auto, method='mean',
|
||||
Curve items, by thinning the pen and activating downsampling auto, method='mean',
|
||||
if the data length exceeds N points. If the data length is less than N points, the
|
||||
symbol will be activated and downsampling will be deactivated. Maximum points will be
|
||||
5x the limit.
|
||||
pen is restored and downsampling deactivated. Maximum points will be 5x the limit.
|
||||
|
||||
The symbol is not handled here: `Curve.setData` hides it above
|
||||
`CurveConfig.symbol_point_limit` for every curve, sync or async.
|
||||
|
||||
Args:
|
||||
curve(Curve): The curve to adjust.
|
||||
@@ -2364,14 +2366,11 @@ class Waveform(PlotBase):
|
||||
logger.warning("Limit must be greater than 1.")
|
||||
return
|
||||
if data_length > limit:
|
||||
if curve.config.symbol is not None:
|
||||
curve.set_symbol(None)
|
||||
if curve.config.pen_width > 3:
|
||||
curve.set_pen_width(3)
|
||||
curve.setDownsampling(ds=None, auto=True, method=method)
|
||||
curve.setClipToView(True)
|
||||
elif data_length <= limit:
|
||||
curve.set_symbol("o")
|
||||
curve.set_pen_width(4)
|
||||
curve.setDownsampling(ds=1, auto=None, method=method)
|
||||
curve.setClipToView(True)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""The symbol is dropped for dense curves; see Curve.setData."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, _incoming_length
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
LIMIT = 1000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curve(qtbot, mocked_client):
|
||||
waveform = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
waveform.plot(arg1="bpm4i")
|
||||
return waveform.curves[0]
|
||||
|
||||
|
||||
def _data(n: int):
|
||||
x = np.arange(n, dtype=np.float64)
|
||||
return x, np.sin(x * 0.01)
|
||||
|
||||
|
||||
def test_symbol_kept_at_or_below_limit(curve):
|
||||
curve.setData(*_data(LIMIT))
|
||||
assert curve.opts["symbol"] == "o"
|
||||
|
||||
|
||||
def test_symbol_hidden_above_limit(curve):
|
||||
curve.setData(*_data(LIMIT + 1))
|
||||
assert curve.opts["symbol"] is None
|
||||
|
||||
|
||||
def test_config_symbol_survives_suppression(curve):
|
||||
"""The configured symbol is a user setting, not something to overwrite."""
|
||||
curve.setData(*_data(LIMIT + 1))
|
||||
assert curve.config.symbol == "o"
|
||||
|
||||
|
||||
def test_symbol_restored_when_data_shrinks(curve):
|
||||
curve.setData(*_data(LIMIT + 1))
|
||||
assert curve.opts["symbol"] is None
|
||||
curve.setData(*_data(10))
|
||||
assert curve.opts["symbol"] == "o"
|
||||
|
||||
|
||||
def test_custom_symbol_restored_not_default(curve):
|
||||
"""Restoring must use the configured symbol, not a hardcoded 'o'."""
|
||||
curve.set_symbol("t")
|
||||
curve.setData(*_data(LIMIT + 1))
|
||||
assert curve.opts["symbol"] is None
|
||||
curve.setData(*_data(10))
|
||||
assert curve.opts["symbol"] == "t"
|
||||
|
||||
|
||||
def test_set_symbol_while_dense_defers_until_sparse(curve):
|
||||
curve.setData(*_data(LIMIT + 1))
|
||||
curve.set_symbol("x")
|
||||
# still dense: the request is recorded but not shown
|
||||
assert curve.config.symbol == "x"
|
||||
assert curve.opts["symbol"] is None
|
||||
curve.setData(*_data(10))
|
||||
assert curve.opts["symbol"] == "x"
|
||||
|
||||
|
||||
def test_apply_config_does_not_resurrect_symbol_while_dense(curve):
|
||||
curve.setData(*_data(LIMIT + 1))
|
||||
curve.apply_config()
|
||||
assert curve.opts["symbol"] is None
|
||||
|
||||
|
||||
def test_limit_none_keeps_symbol_at_any_size(qtbot, mocked_client):
|
||||
waveform = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
waveform.plot(arg1="bpm4i")
|
||||
curve = waveform.curves[0]
|
||||
curve.config.symbol_point_limit = None
|
||||
curve.setData(*_data(50_000))
|
||||
assert curve.opts["symbol"] == "o"
|
||||
|
||||
|
||||
def test_custom_limit_is_honoured(curve):
|
||||
curve.config.symbol_point_limit = 10
|
||||
curve.setData(*_data(11))
|
||||
assert curve.opts["symbol"] is None
|
||||
curve.setData(*_data(10))
|
||||
assert curve.opts["symbol"] == "o"
|
||||
|
||||
|
||||
def test_default_limit_is_1000():
|
||||
assert CurveConfig(widget_class="Curve").symbol_point_limit == LIMIT
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args,kwargs,expected",
|
||||
[
|
||||
((np.arange(5),), {}, 5),
|
||||
((np.arange(5), np.arange(5)), {}, 5),
|
||||
(((1, 2, 3),), {}, 3),
|
||||
(([1, 2, 3, 4],), {}, 4),
|
||||
((), {"y": np.arange(7)}, 7),
|
||||
((), {"x": np.arange(7)}, 7),
|
||||
((), {}, 0),
|
||||
(({"x": [1], "y": [2]},), {}, None),
|
||||
(([{"pos": (0, 0)}],), {}, None),
|
||||
],
|
||||
)
|
||||
def test_incoming_length(args, kwargs, expected):
|
||||
assert _incoming_length(args, kwargs) == expected
|
||||
|
||||
|
||||
def test_unresolvable_length_leaves_symbol_untouched(curve):
|
||||
"""A shape we cannot measure must not silently flip the symbol."""
|
||||
curve.setData(*_data(10))
|
||||
assert curve.opts["symbol"] == "o"
|
||||
curve.setData({"x": np.arange(5000), "y": np.arange(5000)})
|
||||
assert curve.opts["symbol"] == "o"
|
||||
Reference in New Issue
Block a user