perf(waveform): hide curve symbols above 1000 points

This commit is contained in:
2026-08-14 12:44:47 +02:00
parent 18dc005f58
commit 8e1979f306
3 changed files with 214 additions and 14 deletions
+90 -8
View File
@@ -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)
+119
View File
@@ -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"