diff --git a/bec_widgets/widgets/plots/waveform/curve.py b/bec_widgets/widgets/plots/waveform/curve.py index efc61504..c9812798 100644 --- a/bec_widgets/widgets/plots/waveform/curve.py +++ b/bec_widgets/widgets/plots/waveform/curve.py @@ -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): """ diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index f87f8c77..90972aef 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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) diff --git a/tests/unit_tests/test_curve_symbol_limit.py b/tests/unit_tests/test_curve_symbol_limit.py new file mode 100644 index 00000000..1105a57f --- /dev/null +++ b/tests/unit_tests/test_curve_symbol_limit.py @@ -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"