diff --git a/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog.pyproject b/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog.pyproject new file mode 100644 index 00000000..8312556b --- /dev/null +++ b/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog.pyproject @@ -0,0 +1 @@ +{'files': ['feedback_dialog.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog_plugin.py b/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog_plugin.py new file mode 100644 index 00000000..12b346fe --- /dev/null +++ b/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog + +DOM_XML = """ + + + + +""" + + +class FeedbackDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = FeedbackDialog(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(FeedbackDialog.ICON_NAME) + + def includeFile(self): + return "feedback_dialog" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "FeedbackDialog" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/utility/feedback_dialog/register_feedback_dialog.py b/bec_widgets/widgets/utility/feedback_dialog/register_feedback_dialog.py new file mode 100644 index 00000000..0005c183 --- /dev/null +++ b/bec_widgets/widgets/utility/feedback_dialog/register_feedback_dialog.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog_plugin import FeedbackDialogPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(FeedbackDialogPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/unit_tests/benchmarks/test_gpu_widget_benchmark.py b/tests/unit_tests/benchmarks/test_gpu_widget_benchmark.py new file mode 100644 index 00000000..4e3768d7 --- /dev/null +++ b/tests/unit_tests/benchmarks/test_gpu_widget_benchmark.py @@ -0,0 +1,266 @@ +"""End-to-end render benchmark for Waveform and Image, with and without OpenGL. + +Drives each widget through the *real* data-entry point of whichever branch it is +run on, so the numbers include the widget-side data handling, not just pyqtgraph: + +- ``data_api`` branch: a ``SubscriptionUpdate`` is handed to ``_on_data_update``, + which includes the columnar alignment work. +- ``main``: ``update_sync_curves`` pulls a prepared scan-data dict (the fetch from + the scan item is stubbed, since that needs a live BEC), and the image widget is + fed through ``on_image_update_2d``. + +The network/Redis hop is outside the measured region on both branches -- what is +compared is "a data snapshot is available -> pixels on screen". + +Run explicitly (it is excluded from the CI suite by ``--ignore``):: + + QT_QPA_PLATFORM=offscreen pytest tests/unit_tests/benchmarks/test_gpu_widget_benchmark.py -s + +Environment knobs: ``BENCH_FRAMES`` (default 40), ``BENCH_POINTS`` (default 50000), +``BENCH_IMAGE`` (image edge length, default 2048). +""" + +import os +import time + +import numpy as np +import pyqtgraph as pg +import pytest + +from bec_widgets.utils.gpu_acceleration import opengl_available, set_view_opengl +from bec_widgets.widgets.plots.image.image import Image +from bec_widgets.widgets.plots.waveform.waveform import Waveform +from tests.unit_tests.client_mocks import mocked_client +from tests.unit_tests.conftest import create_widget + +try: + from bec_lib.data_api.models import SourceData, SubscriptionUpdate + + HAS_DATA_API = True +except ImportError: # main branch + HAS_DATA_API = False + +FRAMES = int(os.environ.get("BENCH_FRAMES", 40)) +POINTS = int(os.environ.get("BENCH_POINTS", 50_000)) +IMAGE_EDGE = int(os.environ.get("BENCH_IMAGE", 2048)) +N_PRESET = 8 +DEVICE, ENTRY = "bpm4i", "bpm4i" + +_results: list[tuple[str, str, float | None]] = [] +_data_path: dict[str, list[float]] = {} + + +def _make_update(values: np.ndarray, ordinals: tuple[int, ...], kind: str = "monitored"): + """Build a single-source SubscriptionUpdate carrying `values`.""" + source = SourceData( + device=DEVICE, + entry=ENTRY, + kind=kind, + ordinals=ordinals, + values=tuple(values), + timestamps=tuple(float(o) for o in ordinals), + complete=True, + ) + return SubscriptionUpdate( + scan_id="bench-scan", + reason="live", + sources={(DEVICE, ENTRY): source}, + aligned_ordinals=ordinals, + complete=True, + ) + + +def _time_frames(widget, inject, count_paint_on, qtbot) -> float | None: + """Run FRAMES inject-and-render cycles; None if the frames never rendered. + + Each frame waits for the item to actually paint before starting the next. + ``repaint()`` is not usable here: on a QOpenGLWidget it defers, so a + synchronous count sees zero. An unexposed window also makes painting a no-op + for the raster viewport while OpenGL still renders, which would silently + flatter raster -- hence the explicit per-frame confirmation. + """ + paints = 0 + original = count_paint_on.paint + + def counting_paint(self, *args, **kwargs): + nonlocal paints + paints += 1 + return original(self, *args, **kwargs) + + count_paint_on.paint = counting_paint + rendered = 0 + try: + # The first paint after the window appears needs a generous stretch of + # event loop; 1 ms slices never get there. Warm up until one lands, then + # the per-frame polling below keeps up on its own. + warmup_deadline = time.perf_counter() + 10.0 + while paints == 0 and time.perf_counter() < warmup_deadline: + inject(0) + widget.plot_widget.viewport().update() + try: + qtbot.waitUntil(lambda: paints > 0, timeout=1000) + except Exception: + pass + if paints == 0: + return None + + start = time.perf_counter() + for i in range(FRAMES): + before = paints + inject(i) + widget.plot_widget.viewport().update() + # waitUntil spins pytest-qt's own event loop; hand-rolled + # processEvents()/wait() polling does not deliver these paint events. + try: + qtbot.waitUntil(lambda: paints > before, timeout=5000) + rendered += 1 + except Exception: + pass + elapsed = time.perf_counter() - start + finally: + count_paint_on.paint = original + + if rendered < FRAMES: + print( + f" [diag] rendered={rendered}/{FRAMES} paints={paints} " + f"viewport={type(widget.plot_widget.viewport()).__name__} " + f"visible={widget.isVisible()} items={len(widget.plot_widget.scene().items())}" + ) + return None + return FRAMES / elapsed + + +def _time_data_path(inject, iterations: int) -> float: + """Time the widget-side data handling alone, in ms per update. + + This is the part that differs between branches (DataAPI columnar alignment + vs. the scan-item dict walk) and it needs no window, so unlike the render + timing it is reliable regardless of whether the session composites. + """ + inject(0) # warm caches + start = time.perf_counter() + for i in range(iterations): + inject(i) + return (time.perf_counter() - start) * 1000 / iterations + + +def _report(label: str, mode: str, fps: float | None) -> None: + _results.append((label, mode, fps)) + if fps is None: + print(f" {label:<26} {mode:<8} INVALID (frames did not render)") + else: + print(f" {label:<26} {mode:<8} {fps:8.1f} fps ({1000 / fps:7.2f} ms/frame)") + + +def _setup_waveform(qtbot, mocked_client, use_opengl: bool): + wf = create_widget(qtbot, Waveform, client=mocked_client) + set_view_opengl(wf.plot_widget, use_opengl) + wf.plot(arg1=DEVICE) + wf.resize(1200, 800) + wf.show() + qtbot.waitExposed(wf) + return wf + + +def _waveform_injector(wf, x, datasets): + """Return a per-frame injector using this branch's real data path.""" + if HAS_DATA_API: + ordinals = tuple(range(POINTS)) + updates = [_make_update(d, ordinals) for d in datasets] + return lambda i: wf._on_data_update(updates[i % N_PRESET]) + + # main: update_sync_curves() reads the scan data dict it fetches + payloads = [{DEVICE: {ENTRY: {"val": d}}} for d in datasets] + state = {"i": 0} + + def fetch(): + return payloads[state["i"] % N_PRESET], "val" + + wf._fetch_scan_data_and_access = fetch + wf._get_x_data = lambda *a, **k: x + + def inject(i): + state["i"] = i + wf.update_sync_curves() + + return inject + + +def _setup_image(qtbot, mocked_client, use_opengl: bool): + img = create_widget(qtbot, Image, client=mocked_client) + set_view_opengl(img.plot_widget, use_opengl) + img.image(DEVICE, ENTRY) + img.resize(1200, 800) + img.show() + qtbot.waitExposed(img) + return img + + +def _image_injector(img, frames): + if HAS_DATA_API: + img._source_key = (DEVICE, ENTRY) + img.subscriptions["main"].monitor_type = "2d" + # a plain list keeps each frame a float32 ndarray; np.array([f], dtype=object) + # yields an object-dtype array and the downstream isnan() blows up + updates = [_make_update([f], (0,), kind="async") for f in frames] + return lambda i: img._on_data_update(updates[i % N_PRESET]) + + img.async_update = False + return lambda i: img.on_image_update_2d({"data": frames[i % N_PRESET]}, {}) + + +@pytest.mark.parametrize("use_opengl", [False, True], ids=["raster", "opengl"]) +def test_waveform_render_benchmark(qtbot, mocked_client, use_opengl): + if use_opengl and not opengl_available(True): + pytest.skip("no hardware OpenGL available") + + rng = np.random.default_rng(0) + x = np.arange(POINTS, dtype=np.float64) + datasets = [np.sin(x * 0.001 + k) + rng.normal(0, 0.1, POINTS) for k in range(N_PRESET)] + + wf = _setup_waveform(qtbot, mocked_client, use_opengl) + inject = _waveform_injector(wf, x, datasets) + inject(0) # warm up the curve/GL state before timing + qtbot.wait(200) + + ms = _time_data_path(inject, FRAMES) + fps = _time_frames(wf, inject, pg.PlotCurveItem, qtbot) + _report(f"Waveform {POINTS:,}pts", "opengl" if use_opengl else "raster", fps) + _data_path.setdefault(f"Waveform {POINTS:,}pts", []).append(ms) + + +@pytest.mark.parametrize("use_opengl", [False, True], ids=["raster", "opengl"]) +def test_image_render_benchmark(qtbot, mocked_client, use_opengl): + if use_opengl and not opengl_available(True): + pytest.skip("no hardware OpenGL available") + + rng = np.random.default_rng(0) + frames = [rng.normal(size=(IMAGE_EDGE, IMAGE_EDGE)).astype(np.float32) for _ in range(N_PRESET)] + + img = _setup_image(qtbot, mocked_client, use_opengl) + inject = _image_injector(img, frames) + inject(0) + qtbot.wait(200) + + ms = _time_data_path(inject, FRAMES) + fps = _time_frames(img, inject, pg.ImageItem, qtbot) + _report(f"Image {IMAGE_EDGE}x{IMAGE_EDGE}", "opengl" if use_opengl else "raster", fps) + _data_path.setdefault(f"Image {IMAGE_EDGE}x{IMAGE_EDGE}", []).append(ms) + + +def teardown_module(module): + branch = "data_api" if HAS_DATA_API else "main" + print(f"\n==== summary ({branch} data path, {FRAMES} frames) ====") + by_label: dict[str, dict[str, float | None]] = {} + for label, mode, fps in _results: + by_label.setdefault(label, {})[mode] = fps + for label, modes in by_label.items(): + raster, opengl = modes.get("raster"), modes.get("opengl") + if raster and opengl: + print(f" {label:<26} render speedup {opengl / raster:5.2f}x") + else: + print(f" {label:<26} render speedup n/a (a run did not render)") + print(f"---- widget-side data path ({branch}) ----") + for label, samples in _data_path.items(): + best = min(samples) + print(f" {label:<26} {best:8.3f} ms/update (best of {len(samples)})")