bec-widgets 1.25.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. .gitlab-ci.yml +11 -6
  2. CHANGELOG.md +650 -0
  3. PKG-INFO +3 -3
  4. bec_widgets/__init__.py +4 -0
  5. bec_widgets/applications/bw_launch.py +23 -0
  6. bec_widgets/applications/launch_window.py +430 -0
  7. bec_widgets/assets/app_icons/auto_update.png +0 -0
  8. bec_widgets/assets/app_icons/ui_loader_tile.png +0 -0
  9. bec_widgets/cli/__init__.py +0 -1
  10. bec_widgets/cli/client.py +1779 -2064
  11. bec_widgets/cli/client_utils.py +346 -174
  12. bec_widgets/cli/generate_cli.py +143 -37
  13. bec_widgets/cli/rpc/rpc_base.py +152 -21
  14. bec_widgets/cli/rpc/rpc_register.py +113 -6
  15. bec_widgets/cli/rpc/rpc_widget_handler.py +13 -11
  16. bec_widgets/cli/server.py +125 -239
  17. bec_widgets/examples/jupyter_console/jupyter_console_window.py +97 -145
  18. bec_widgets/examples/plugin_example_pyside/tictactoetaskmenu.py +1 -1
  19. bec_widgets/utils/bec_connector.py +190 -21
  20. bec_widgets/utils/bec_designer.py +7 -0
  21. bec_widgets/utils/bec_dispatcher.py +71 -4
  22. bec_widgets/utils/bec_plugin_helper.py +89 -0
  23. bec_widgets/utils/bec_signal_proxy.py +1 -1
  24. bec_widgets/utils/bec_widget.py +26 -10
  25. bec_widgets/utils/colors.py +1 -1
  26. bec_widgets/{qt_utils → utils}/compact_popup.py +2 -0
  27. bec_widgets/utils/container_utils.py +37 -12
  28. bec_widgets/utils/crosshair.py +25 -8
  29. bec_widgets/utils/entry_validator.py +3 -1
  30. bec_widgets/{qt_utils → utils}/error_popups.py +18 -0
  31. bec_widgets/{qt_utils → utils}/expandable_frame.py +2 -2
  32. bec_widgets/utils/forms_from_types/forms.py +182 -0
  33. bec_widgets/{widgets/editors/scan_metadata/_metadata_widgets.py → utils/forms_from_types/items.py} +41 -30
  34. bec_widgets/utils/generate_designer_plugin.py +40 -36
  35. bec_widgets/utils/linear_region_selector.py +2 -0
  36. bec_widgets/utils/name_utils.py +16 -0
  37. bec_widgets/{qt_utils → utils}/palette_viewer.py +2 -2
  38. bec_widgets/utils/plot_indicator_items.py +2 -5
  39. bec_widgets/utils/plugin_utils.py +47 -1
  40. bec_widgets/{qt_utils → utils}/round_frame.py +14 -14
  41. bec_widgets/utils/rpc_server.py +277 -0
  42. bec_widgets/utils/serialization.py +44 -0
  43. bec_widgets/{qt_utils → utils}/settings_dialog.py +26 -1
  44. bec_widgets/{qt_utils → utils}/side_panel.py +17 -10
  45. bec_widgets/{qt_utils → utils}/toolbar.py +69 -25
  46. bec_widgets/utils/ui_loader.py +8 -8
  47. bec_widgets/utils/widget_io.py +166 -25
  48. bec_widgets/widgets/containers/auto_update/auto_updates.py +364 -0
  49. bec_widgets/widgets/containers/dock/dock.py +157 -49
  50. bec_widgets/widgets/containers/dock/dock_area.py +186 -138
  51. bec_widgets/widgets/containers/layout_manager/layout_manager.py +2 -1
  52. bec_widgets/widgets/containers/main_window/addons/web_links.py +15 -0
  53. bec_widgets/widgets/containers/main_window/main_window.py +189 -41
  54. bec_widgets/widgets/control/buttons/button_abort/button_abort.py +3 -4
  55. bec_widgets/widgets/control/buttons/button_reset/button_reset.py +3 -4
  56. bec_widgets/widgets/control/buttons/button_resume/button_resume.py +3 -3
  57. bec_widgets/widgets/control/buttons/stop_button/stop_button.py +18 -7
  58. bec_widgets/widgets/control/device_control/position_indicator/position_indicator.py +22 -3
  59. bec_widgets/widgets/control/device_control/positioner_box/_base/positioner_box_base.py +37 -18
  60. bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +28 -4
  61. bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.ui +27 -4
  62. bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +5 -2
  63. bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.ui +97 -31
  64. bec_widgets/widgets/control/device_control/positioner_box/positioner_control_line/positioner_control_line.ui +11 -4
  65. bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py +2 -3
  66. bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +29 -4
  67. bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +1 -0
  68. bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +2 -2
  69. bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +2 -2
  70. bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +1 -2
  71. bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +1 -2
  72. bec_widgets/widgets/control/scan_control/scan_control.py +7 -5
  73. bec_widgets/widgets/control/scan_control/scan_group_box.py +28 -5
  74. bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +1 -2
  75. bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py +3 -4
  76. bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_vertical.ui +14 -8
  77. bec_widgets/widgets/editors/console/console.py +1 -1
  78. bec_widgets/widgets/editors/{scan_metadata/additional_metadata_table.py → dict_backed_table.py} +29 -6
  79. bec_widgets/widgets/editors/scan_metadata/__init__.py +0 -7
  80. bec_widgets/widgets/editors/scan_metadata/_util.py +1 -1
  81. bec_widgets/widgets/{plots/motor_map/register_bec_motor_map_widget.py → editors/scan_metadata/register_scan_metadata.py} +2 -4
  82. bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +42 -136
  83. bec_widgets/widgets/editors/scan_metadata/scan_metadata.pyproject +1 -0
  84. bec_widgets/widgets/{plots/multi_waveform/bec_multi_waveform_widget_plugin.py → editors/scan_metadata/scan_metadata_plugin.py} +9 -9
  85. bec_widgets/widgets/editors/text_box/text_box.py +2 -3
  86. bec_widgets/widgets/editors/website/website.py +2 -2
  87. bec_widgets/widgets/games/minesweeper.py +3 -2
  88. bec_widgets/widgets/plots/image/image.py +960 -0
  89. bec_widgets/widgets/plots/image/image.pyproject +1 -0
  90. bec_widgets/widgets/plots/image/image_item.py +279 -0
  91. bec_widgets/widgets/plots/{motor_map/bec_motor_map_widget_plugin.py → image/image_plugin.py} +11 -13
  92. bec_widgets/widgets/{containers/figure/plots → plots}/image/image_processor.py +31 -64
  93. bec_widgets/widgets/plots/image/{register_bec_image_widget.py → register_image.py} +2 -2
  94. bec_widgets/widgets/plots/image/toolbar_bundles/image_selection.py +59 -0
  95. bec_widgets/widgets/plots/image/toolbar_bundles/processing.py +79 -0
  96. bec_widgets/widgets/plots/motor_map/motor_map.py +832 -0
  97. bec_widgets/widgets/plots/motor_map/motor_map.pyproject +1 -0
  98. bec_widgets/widgets/plots/motor_map/motor_map_plugin.py +54 -0
  99. bec_widgets/widgets/plots/{multi_waveform/register_bec_multi_waveform_widget.py → motor_map/register_motor_map.py} +2 -4
  100. bec_widgets/widgets/plots/motor_map/settings/motor_map_settings.py +129 -0
  101. bec_widgets/widgets/plots/motor_map/settings/motor_map_settings.ui +120 -0
  102. bec_widgets/widgets/plots/motor_map/toolbar_bundles/motor_selection.py +70 -0
  103. bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +508 -0
  104. bec_widgets/widgets/plots/multi_waveform/multi_waveform.pyproject +1 -0
  105. bec_widgets/widgets/plots/multi_waveform/multi_waveform_plugin.py +54 -0
  106. bec_widgets/widgets/plots/multi_waveform/register_multi_waveform.py +15 -0
  107. bec_widgets/widgets/plots/multi_waveform/settings/control_panel.py +144 -0
  108. bec_widgets/widgets/plots/multi_waveform/settings/multi_waveform_controls.ui +164 -0
  109. bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py +65 -0
  110. bec_widgets/widgets/{plots_next_gen → plots}/plot_base.py +321 -40
  111. bec_widgets/widgets/plots/{waveform/register_bec_waveform_widget.py → scatter_waveform/register_scatter_waveform.py} +3 -3
  112. bec_widgets/widgets/plots/scatter_waveform/scatter_curve.py +197 -0
  113. bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +553 -0
  114. bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.pyproject +1 -0
  115. bec_widgets/widgets/plots/{image/bec_image_widget_plugin.py → scatter_waveform/scatter_waveform_plugin.py} +9 -13
  116. bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py +138 -0
  117. bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui +195 -0
  118. bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_vertical.ui +204 -0
  119. bec_widgets/widgets/{plots_next_gen → plots}/setting_menus/axis_settings.py +8 -8
  120. bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/mouse_interactions.py +4 -18
  121. bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/plot_export.py +14 -3
  122. bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/roi_bundle.py +6 -1
  123. bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/save_state.py +2 -2
  124. bec_widgets/widgets/{containers/figure/plots/waveform/waveform_curve.py → plots/waveform/curve.py} +119 -49
  125. bec_widgets/widgets/plots/waveform/register_waveform.py +15 -0
  126. bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_setting.py +125 -0
  127. bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +576 -0
  128. bec_widgets/widgets/plots/waveform/utils/__init__.py +0 -0
  129. bec_widgets/widgets/plots/waveform/utils/roi_manager.py +84 -0
  130. bec_widgets/widgets/plots/waveform/waveform.py +1794 -0
  131. bec_widgets/widgets/plots/waveform/waveform.pyproject +1 -0
  132. bec_widgets/widgets/plots/waveform/{bec_waveform_widget_plugin.py → waveform_plugin.py} +9 -13
  133. bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +1 -2
  134. bec_widgets/widgets/progress/ring_progress_bar/ring.py +11 -10
  135. bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +24 -14
  136. bec_widgets/widgets/services/bec_queue/bec_queue.py +13 -11
  137. bec_widgets/widgets/services/bec_status_box/bec_status_box.py +3 -4
  138. bec_widgets/widgets/services/device_browser/device_browser.py +5 -2
  139. bec_widgets/widgets/services/device_browser/device_item/device_item.py +1 -1
  140. bec_widgets/widgets/utility/logpanel/logpanel.py +36 -17
  141. bec_widgets/widgets/utility/spinbox/decimal_spinbox.py +3 -3
  142. bec_widgets/widgets/utility/spinner/spinner.py +2 -2
  143. bec_widgets/widgets/utility/visual/color_button/color_button.py +1 -1
  144. bec_widgets/widgets/utility/visual/colormap_widget/colormap_widget.py +4 -6
  145. bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py +4 -8
  146. {bec_widgets-1.25.0.dist-info → bec_widgets-2.0.0.dist-info}/METADATA +3 -3
  147. {bec_widgets-1.25.0.dist-info → bec_widgets-2.0.0.dist-info}/RECORD +169 -154
  148. pyproject.toml +3 -3
  149. bec_widgets/applications/alignment/alignment_1d/alignment_1d.py +0 -198
  150. bec_widgets/applications/alignment/alignment_1d/alignment_1d.ui +0 -615
  151. bec_widgets/applications/bec_app.py +0 -84
  152. bec_widgets/cli/auto_updates.py +0 -168
  153. bec_widgets/widgets/containers/figure/__init__.py +0 -1
  154. bec_widgets/widgets/containers/figure/figure.py +0 -796
  155. bec_widgets/widgets/containers/figure/plots/axis_settings.py +0 -91
  156. bec_widgets/widgets/containers/figure/plots/axis_settings.ui +0 -256
  157. bec_widgets/widgets/containers/figure/plots/image/image.py +0 -772
  158. bec_widgets/widgets/containers/figure/plots/image/image_item.py +0 -337
  159. bec_widgets/widgets/containers/figure/plots/motor_map/motor_map.py +0 -525
  160. bec_widgets/widgets/containers/figure/plots/multi_waveform/multi_waveform.py +0 -340
  161. bec_widgets/widgets/containers/figure/plots/plot_base.py +0 -505
  162. bec_widgets/widgets/containers/figure/plots/waveform/waveform.py +0 -1563
  163. bec_widgets/widgets/plots/image/bec_image_widget.pyproject +0 -1
  164. bec_widgets/widgets/plots/image/image_widget.py +0 -515
  165. bec_widgets/widgets/plots/motor_map/bec_motor_map_widget.pyproject +0 -1
  166. bec_widgets/widgets/plots/motor_map/motor_map_dialog/motor_map_settings.py +0 -56
  167. bec_widgets/widgets/plots/motor_map/motor_map_dialog/motor_map_settings.ui +0 -108
  168. bec_widgets/widgets/plots/motor_map/motor_map_widget.py +0 -234
  169. bec_widgets/widgets/plots/multi_waveform/bec_multi_waveform_widget.pyproject +0 -1
  170. bec_widgets/widgets/plots/multi_waveform/multi_waveform_controls.ui +0 -99
  171. bec_widgets/widgets/plots/multi_waveform/multi_waveform_widget.py +0 -536
  172. bec_widgets/widgets/plots/waveform/bec_waveform_widget.pyproject +0 -1
  173. bec_widgets/widgets/plots/waveform/waveform_popups/curve_dialog/curve_dialog.py +0 -336
  174. bec_widgets/widgets/plots/waveform/waveform_popups/curve_dialog/curve_dialog.ui +0 -372
  175. bec_widgets/widgets/plots/waveform/waveform_popups/dap_summary_dialog/dap_summary_dialog.py +0 -25
  176. bec_widgets/widgets/plots/waveform/waveform_widget.py +0 -751
  177. /bec_widgets/{qt_utils → utils}/collapsible_panel_manager.py +0 -0
  178. /bec_widgets/{applications/alignment → utils/forms_from_types}/__init__.py +0 -0
  179. /bec_widgets/{qt_utils → utils}/redis_message_waiter.py +0 -0
  180. /bec_widgets/{applications/alignment/alignment_1d → widgets/containers/auto_update}/__init__.py +0 -0
  181. /bec_widgets/{qt_utils → widgets/containers/main_window/addons}/__init__.py +0 -0
  182. /bec_widgets/widgets/{containers/figure/plots → plots/image/toolbar_bundles}/__init__.py +0 -0
  183. /bec_widgets/widgets/{containers/figure/plots/image → plots/motor_map/settings}/__init__.py +0 -0
  184. /bec_widgets/widgets/{containers/figure/plots/motor_map → plots/motor_map/toolbar_bundles}/__init__.py +0 -0
  185. /bec_widgets/widgets/{containers/figure/plots/multi_waveform → plots/multi_waveform/settings}/__init__.py +0 -0
  186. /bec_widgets/widgets/{containers/figure/plots/waveform → plots/multi_waveform/toolbar_bundles}/__init__.py +0 -0
  187. /bec_widgets/widgets/plots/{motor_map/motor_map_dialog → scatter_waveform}/__init__.py +0 -0
  188. /bec_widgets/widgets/plots/{waveform/waveform_popups → scatter_waveform/settings}/__init__.py +0 -0
  189. /bec_widgets/widgets/plots/{waveform/waveform_popups/curve_dialog → setting_menus}/__init__.py +0 -0
  190. /bec_widgets/widgets/{plots_next_gen → plots}/setting_menus/axis_settings_horizontal.ui +0 -0
  191. /bec_widgets/widgets/{plots_next_gen → plots}/setting_menus/axis_settings_vertical.ui +0 -0
  192. /bec_widgets/widgets/plots/{waveform/waveform_popups/dap_summary_dialog → toolbar_bundles}/__init__.py +0 -0
  193. /bec_widgets/widgets/{plots_next_gen/setting_menus → plots/waveform/settings}/__init__.py +0 -0
  194. /bec_widgets/widgets/{plots_next_gen/toolbar_bundles → plots/waveform/settings/curve_settings}/__init__.py +0 -0
  195. {bec_widgets-1.25.0.dist-info → bec_widgets-2.0.0.dist-info}/WHEEL +0 -0
  196. {bec_widgets-1.25.0.dist-info → bec_widgets-2.0.0.dist-info}/entry_points.txt +0 -0
  197. {bec_widgets-1.25.0.dist-info → bec_widgets-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1794 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Literal
5
+
6
+ import lmfit
7
+ import numpy as np
8
+ import pyqtgraph as pg
9
+ from bec_lib import bec_logger, messages
10
+ from bec_lib.endpoints import MessageEndpoints
11
+ from pydantic import Field, ValidationError, field_validator
12
+ from qtpy.QtCore import QTimer, Signal
13
+ from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
14
+
15
+ from bec_widgets.utils import ConnectionConfig
16
+ from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
17
+ from bec_widgets.utils.colors import Colors, set_theme
18
+ from bec_widgets.utils.container_utils import WidgetContainerUtils
19
+ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
20
+ from bec_widgets.utils.settings_dialog import SettingsDialog
21
+ from bec_widgets.utils.toolbar import MaterialIconAction
22
+ from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
23
+ from bec_widgets.widgets.plots.plot_base import PlotBase
24
+ from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
25
+ from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
26
+ from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
27
+
28
+ logger = bec_logger.logger
29
+
30
+
31
+ # noinspection PyDataclass
32
+ class WaveformConfig(ConnectionConfig):
33
+ color_palette: str | None = Field(
34
+ "plasma", description="The color palette of the figure widget.", validate_default=True
35
+ )
36
+
37
+ model_config: dict = {"validate_assignment": True}
38
+ _validate_color_palette = field_validator("color_palette")(Colors.validate_color_map)
39
+
40
+
41
+ class Waveform(PlotBase):
42
+ """
43
+ Widget for plotting waveforms.
44
+ """
45
+
46
+ PLUGIN = True
47
+ RPC = True
48
+ ICON_NAME = "show_chart"
49
+ USER_ACCESS = [
50
+ # General PlotBase Settings
51
+ "_config_dict",
52
+ "enable_toolbar",
53
+ "enable_toolbar.setter",
54
+ "enable_side_panel",
55
+ "enable_side_panel.setter",
56
+ "enable_fps_monitor",
57
+ "enable_fps_monitor.setter",
58
+ "set",
59
+ "title",
60
+ "title.setter",
61
+ "x_label",
62
+ "x_label.setter",
63
+ "y_label",
64
+ "y_label.setter",
65
+ "x_limits",
66
+ "x_limits.setter",
67
+ "y_limits",
68
+ "y_limits.setter",
69
+ "x_grid",
70
+ "x_grid.setter",
71
+ "y_grid",
72
+ "y_grid.setter",
73
+ "inner_axes",
74
+ "inner_axes.setter",
75
+ "outer_axes",
76
+ "outer_axes.setter",
77
+ "lock_aspect_ratio",
78
+ "lock_aspect_ratio.setter",
79
+ "auto_range_x",
80
+ "auto_range_x.setter",
81
+ "auto_range_y",
82
+ "auto_range_y.setter",
83
+ "x_log",
84
+ "x_log.setter",
85
+ "y_log",
86
+ "y_log.setter",
87
+ "legend_label_size",
88
+ "legend_label_size.setter",
89
+ # Waveform Specific RPC Access
90
+ "curves",
91
+ "x_mode",
92
+ "x_mode.setter",
93
+ "x_entry",
94
+ "x_entry.setter",
95
+ "color_palette",
96
+ "color_palette.setter",
97
+ "plot",
98
+ "add_dap_curve",
99
+ "remove_curve",
100
+ "update_with_scan_history",
101
+ "get_dap_params",
102
+ "get_dap_summary",
103
+ "get_all_data",
104
+ "get_curve",
105
+ "select_roi",
106
+ "clear_all",
107
+ ]
108
+
109
+ sync_signal_update = Signal()
110
+ async_signal_update = Signal()
111
+ request_dap_update = Signal()
112
+ unblock_dap_proxy = Signal()
113
+ dap_params_update = Signal(dict, dict)
114
+ dap_summary_update = Signal(dict, dict)
115
+ new_scan = Signal()
116
+ new_scan_id = Signal(str)
117
+
118
+ roi_changed = Signal(tuple)
119
+ roi_active = Signal(bool)
120
+ roi_enable = Signal(bool) # enable toolbar icon
121
+
122
+ def __init__(
123
+ self,
124
+ parent: QWidget | None = None,
125
+ config: WaveformConfig | None = None,
126
+ client=None,
127
+ gui_id: str | None = None,
128
+ popups: bool = True,
129
+ **kwargs,
130
+ ):
131
+ if config is None:
132
+ config = WaveformConfig(widget_class=self.__class__.__name__)
133
+ super().__init__(
134
+ parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
135
+ )
136
+
137
+ # Curve data
138
+ self._sync_curves = []
139
+ self._async_curves = []
140
+ self._slice_index = None
141
+ self._dap_curves = []
142
+ self._mode: Literal["none", "sync", "async", "mixed"] = "none"
143
+
144
+ # Scan data
145
+ self.old_scan_id = None
146
+ self.scan_id = None
147
+ self.scan_item = None
148
+ self.readout_priority = None
149
+ self.x_axis_mode = {
150
+ "name": "auto",
151
+ "entry": None,
152
+ "readout_priority": None,
153
+ "label_suffix": "",
154
+ }
155
+
156
+ # Specific GUI elements
157
+ self._init_roi_manager()
158
+ self.dap_summary = None
159
+ self.dap_summary_dialog = None
160
+ self._enable_roi_toolbar_action(False) # default state where are no dap curves
161
+ self._init_curve_dialog()
162
+ self.curve_settings_dialog = None
163
+
164
+ # Scan status update loop
165
+ self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
166
+ self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
167
+
168
+ # Curve update loop
169
+ self.proxy_update_sync = pg.SignalProxy(
170
+ self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
171
+ )
172
+ self.proxy_update_async = pg.SignalProxy(
173
+ self.async_signal_update, rateLimit=25, slot=self.update_async_curves
174
+ )
175
+ self.proxy_dap_request = BECSignalProxy(
176
+ self.request_dap_update, rateLimit=25, slot=self.request_dap, timeout=10.0
177
+ )
178
+ self.unblock_dap_proxy.connect(self.proxy_dap_request.unblock_proxy)
179
+ self.roi_enable.connect(self._enable_roi_toolbar_action)
180
+
181
+ self.update_with_scan_history(-1)
182
+
183
+ # for updating a color scheme of curves
184
+ self._connect_to_theme_change()
185
+ # To fix the ViewAll action with clipToView activated
186
+ self._connect_viewbox_menu_actions()
187
+
188
+ def _connect_viewbox_menu_actions(self):
189
+ """Connect the viewbox menu action ViewAll to the custom reset_view method."""
190
+ menu = self.plot_item.vb.menu
191
+ # Find and replace "View All" action
192
+ for action in menu.actions():
193
+ if action.text() == "View All":
194
+ # Disconnect the default autoRange action
195
+ action.triggered.disconnect()
196
+ # Connect to the custom reset_view method
197
+ action.triggered.connect(self._reset_view)
198
+ break
199
+
200
+ ################################################################################
201
+ # Widget Specific GUI interactions
202
+ ################################################################################
203
+ @SafeSlot(str)
204
+ def apply_theme(self, theme: str):
205
+ """
206
+ Apply the theme to the widget.
207
+
208
+ Args:
209
+ theme(str, optional): The theme to be applied.
210
+ """
211
+ self._refresh_colors()
212
+ super().apply_theme(theme)
213
+
214
+ def add_side_menus(self):
215
+ """
216
+ Add side menus to the Waveform widget.
217
+ """
218
+ super().add_side_menus()
219
+ self._add_dap_summary_side_menu()
220
+
221
+ def add_popups(self):
222
+ """
223
+ Add popups to the Waveform widget.
224
+ """
225
+ super().add_popups()
226
+ LMFitDialog_action = MaterialIconAction(
227
+ icon_name="monitoring", tooltip="Open Fit Parameters", checkable=True, parent=self
228
+ )
229
+ self.toolbar.add_action_to_bundle(
230
+ bundle_id="popup_bundle",
231
+ action_id="fit_params",
232
+ action=LMFitDialog_action,
233
+ target_widget=self,
234
+ )
235
+ self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_dap_summary_popup)
236
+
237
+ @SafeSlot()
238
+ def _reset_view(self):
239
+ """
240
+ Custom _reset_view method to fix ViewAll action in toolbar.
241
+ Due to setting clipToView to True on the curves, the autoRange() method
242
+ of the ViewBox does no longer work as expected. This method deactivates the
243
+ setClipToView for all curves, calls autoRange() to circumvent that issue.
244
+ Afterwards, it re-enables the setClipToView for all curves again.
245
+
246
+ It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
247
+ """
248
+ for curve in self._async_curves + self._sync_curves:
249
+ curve.setClipToView(False)
250
+ self.plot_item.vb.autoRange()
251
+ self.auto_range_x = True
252
+ self.auto_range_y = True
253
+ for curve in self._async_curves + self._sync_curves:
254
+ curve.setClipToView(True)
255
+
256
+ ################################################################################
257
+ # Roi manager
258
+
259
+ def _init_roi_manager(self):
260
+ """
261
+ Initialize the ROI manager for the Waveform widget.
262
+ """
263
+ # Add toolbar icon
264
+ roi = MaterialIconAction(
265
+ icon_name="align_justify_space_between",
266
+ tooltip="Add ROI region for DAP",
267
+ checkable=True,
268
+ )
269
+ self.toolbar.add_action_to_bundle(
270
+ bundle_id="roi", action_id="roi_linear", action=roi, target_widget=self
271
+ )
272
+ self._roi_manager = WaveformROIManager(self.plot_item, parent=self)
273
+
274
+ # Connect manager signals -> forward them via Waveform's own signals
275
+ self._roi_manager.roi_changed.connect(self.roi_changed)
276
+ self._roi_manager.roi_active.connect(self.roi_active)
277
+
278
+ # Example: connect ROI changed to re-request DAP
279
+ self.roi_changed.connect(self._on_roi_changed_for_dap)
280
+ self._roi_manager.roi_active.connect(self.request_dap_update)
281
+ self.toolbar.widgets["roi_linear"].action.toggled.connect(self._roi_manager.toggle_roi)
282
+
283
+ def _init_curve_dialog(self):
284
+ """
285
+ Initializes the Curve dialog within the toolbar.
286
+ """
287
+ curve_settings = MaterialIconAction(
288
+ icon_name="timeline", tooltip="Show Curve dialog.", checkable=True
289
+ )
290
+ self.toolbar.add_action("curve", curve_settings, target_widget=self)
291
+ self.toolbar.widgets["curve"].action.triggered.connect(self.show_curve_settings_popup)
292
+
293
+ def show_curve_settings_popup(self):
294
+ """
295
+ Displays the curve settings popup to allow users to modify curve-related configurations.
296
+ """
297
+ curve_action = self.toolbar.widgets["curve"].action
298
+
299
+ if self.curve_settings_dialog is None or not self.curve_settings_dialog.isVisible():
300
+ curve_setting = CurveSetting(parent=self, target_widget=self)
301
+ self.curve_settings_dialog = SettingsDialog(
302
+ self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
303
+ )
304
+ self.curve_settings_dialog.setFixedWidth(580)
305
+ # When the dialog is closed, update the toolbar icon and clear the reference
306
+ self.curve_settings_dialog.finished.connect(self._curve_settings_closed)
307
+ self.curve_settings_dialog.show()
308
+ curve_action.setChecked(True)
309
+ else:
310
+ # If already open, bring it to the front
311
+ self.curve_settings_dialog.raise_()
312
+ self.curve_settings_dialog.activateWindow()
313
+ curve_action.setChecked(True) # keep it toggled
314
+
315
+ def _curve_settings_closed(self):
316
+ """
317
+ Slot for when the axis settings dialog is closed.
318
+ """
319
+ self.curve_settings_dialog.close()
320
+ self.curve_settings_dialog.deleteLater()
321
+ self.curve_settings_dialog = None
322
+ self.toolbar.widgets["curve"].action.setChecked(False)
323
+
324
+ @property
325
+ def roi_region(self) -> tuple[float, float] | None:
326
+ """
327
+ Allows external code to get/set the ROI region easily via Waveform.
328
+ """
329
+ return self._roi_manager.roi_region
330
+
331
+ @roi_region.setter
332
+ def roi_region(self, value: tuple[float, float] | None):
333
+ """
334
+ Set the ROI region limits.
335
+
336
+ Args:
337
+ value(tuple[float, float] | None): The new ROI region limits.
338
+ """
339
+ self._roi_manager.roi_region = value
340
+
341
+ def select_roi(self, region: tuple[float, float]):
342
+ """
343
+ Public method if you want the old `select_roi` style.
344
+ """
345
+ self._roi_manager.select_roi(region)
346
+
347
+ def toggle_roi(self, enabled: bool):
348
+ """
349
+ Toggle the ROI on or off.
350
+
351
+ Args:
352
+ enabled(bool): Whether to enable or disable the ROI.
353
+ """
354
+ self._roi_manager.toggle_roi(enabled)
355
+
356
+ def _on_roi_changed_for_dap(self):
357
+ """
358
+ Whenever the ROI changes, you might want to re-request DAP with the new x_min, x_max.
359
+ """
360
+ self.request_dap_update.emit()
361
+
362
+ def _enable_roi_toolbar_action(self, enable: bool):
363
+ """
364
+ Enable or disable the ROI toolbar action.
365
+
366
+ Args:
367
+ enable(bool): Enable or disable the ROI toolbar action.
368
+ """
369
+ self.toolbar.widgets["roi_linear"].action.setEnabled(enable)
370
+ if enable is False:
371
+ self.toolbar.widgets["roi_linear"].action.setChecked(False)
372
+ self._roi_manager.toggle_roi(False)
373
+
374
+ ################################################################################
375
+ # Dap Summary
376
+
377
+ def _add_dap_summary_side_menu(self):
378
+ """
379
+ Add the DAP summary to the side panel.
380
+ """
381
+ self.dap_summary = LMFitDialog(parent=self)
382
+ self.side_panel.add_menu(
383
+ action_id="fit_params",
384
+ icon_name="monitoring",
385
+ tooltip="Open Fit Parameters",
386
+ widget=self.dap_summary,
387
+ title="Fit Parameters",
388
+ )
389
+ self.dap_summary_update.connect(self.dap_summary.update_summary_tree)
390
+
391
+ def show_dap_summary_popup(self):
392
+ """
393
+ Show the DAP summary popup.
394
+ """
395
+ fit_action = self.toolbar.widgets["fit_params"].action
396
+ if self.dap_summary_dialog is None or not self.dap_summary_dialog.isVisible():
397
+ self.dap_summary = LMFitDialog(parent=self)
398
+ self.dap_summary_dialog = QDialog(modal=False)
399
+ self.dap_summary_dialog.layout = QVBoxLayout(self.dap_summary_dialog)
400
+ self.dap_summary_dialog.layout.addWidget(self.dap_summary)
401
+ self.dap_summary_update.connect(self.dap_summary.update_summary_tree)
402
+ self.dap_summary_dialog.finished.connect(self._dap_summary_closed)
403
+ self.dap_summary_dialog.show()
404
+ self._refresh_dap_signals() # Get current dap data
405
+ self.dap_summary_dialog.resize(300, 300)
406
+ fit_action.setChecked(True)
407
+ else:
408
+ # If already open, bring it to the front
409
+ self.dap_summary_dialog.raise_()
410
+ self.dap_summary_dialog.activateWindow()
411
+ fit_action.setChecked(True) # keep it toggle
412
+
413
+ def _dap_summary_closed(self):
414
+ """
415
+ Slot for when the axis settings dialog is closed.
416
+ """
417
+ self.dap_summary_dialog.deleteLater()
418
+ self.dap_summary_dialog = None
419
+ self.toolbar.widgets["fit_params"].action.setChecked(False)
420
+
421
+ def _get_dap_from_target_widget(self) -> None:
422
+ """Get the DAP data from the target widget and update the DAP dialog manually on creation."""
423
+ dap_summary = self.get_dap_summary()
424
+ for curve_id, data in dap_summary.items():
425
+ md = {"curve_id": curve_id}
426
+ self.dap_summary.update_summary_tree(data=data, metadata=md)
427
+
428
+ @SafeSlot()
429
+ def get_dap_params(self) -> dict[str, dict]:
430
+ """
431
+ Get the DAP parameters of all DAP curves.
432
+
433
+ Returns:
434
+ dict[str, dict]: DAP parameters of all DAP curves.
435
+ """
436
+ return {curve.name(): curve.dap_params for curve in self._dap_curves}
437
+
438
+ @SafeSlot()
439
+ def get_dap_summary(self) -> dict[str, dict]:
440
+ """
441
+ Get the DAP summary of all DAP curves.
442
+
443
+ Returns:
444
+ dict[str, dict]: DAP summary of all DAP curves.
445
+ """
446
+ return {curve.name(): curve.dap_summary for curve in self._dap_curves}
447
+
448
+ ################################################################################
449
+ # Widget Specific Properties
450
+ ################################################################################
451
+
452
+ @SafeProperty(str)
453
+ def x_mode(self) -> str:
454
+ return self.x_axis_mode["name"]
455
+
456
+ @x_mode.setter
457
+ def x_mode(self, value: str):
458
+ self.x_axis_mode["name"] = value
459
+ if value not in ["timestamp", "index", "auto"]:
460
+ self.x_axis_mode["entry"] = self.entry_validator.validate_signal(value, None)
461
+ self._switch_x_axis_item(mode=value)
462
+ self.async_signal_update.emit()
463
+ self.sync_signal_update.emit()
464
+ self.plot_item.enableAutoRange(x=True)
465
+ self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
466
+
467
+ @SafeProperty(str)
468
+ def x_entry(self) -> str | None:
469
+ """
470
+ The x signal name.
471
+ """
472
+ return self.x_axis_mode["entry"]
473
+
474
+ @x_entry.setter
475
+ def x_entry(self, value: str | None):
476
+ """
477
+ Set the x signal name.
478
+
479
+ Args:
480
+ value(str|None): The x signal name to set.
481
+ """
482
+ if value is None:
483
+ return
484
+ if self.x_axis_mode["name"] in ["auto", "index", "timestamp"]:
485
+ logger.warning("Cannot set x_entry when x_mode is not 'device'.")
486
+ return
487
+ self.x_axis_mode["entry"] = self.entry_validator.validate_signal(self.x_mode, value)
488
+ self._switch_x_axis_item(mode="device")
489
+ self.async_signal_update.emit()
490
+ self.sync_signal_update.emit()
491
+ self.plot_item.enableAutoRange(x=True)
492
+ self.round_plot_widget.apply_plot_widget_style()
493
+
494
+ @SafeProperty(str)
495
+ def color_palette(self) -> str:
496
+ """
497
+ The color palette of the figure widget.
498
+ """
499
+ return self.config.color_palette
500
+
501
+ @color_palette.setter
502
+ def color_palette(self, value: str):
503
+ """
504
+ Set the color palette of the figure widget.
505
+
506
+ Args:
507
+ value(str): The color palette to set.
508
+ """
509
+ try:
510
+ self.config.color_palette = value
511
+ except ValidationError:
512
+ return
513
+
514
+ colors = Colors.golden_angle_color(
515
+ colormap=self.config.color_palette, num=max(10, len(self.curves) + 1), format="HEX"
516
+ )
517
+ for i, curve in enumerate(self.curves):
518
+ curve.set_color(colors[i])
519
+
520
+ @SafeProperty(str, designable=False, popup_error=True)
521
+ def curve_json(self) -> str:
522
+ """
523
+ A JSON string property that serializes all curves' pydantic configs.
524
+ """
525
+ raw_list = []
526
+ for c in self.curves:
527
+ if c.config.source == "custom": # Do not serialize custom curves
528
+ continue
529
+ cfg_dict = c.config.model_dump()
530
+ raw_list.append(cfg_dict)
531
+ return json.dumps(raw_list, indent=2)
532
+
533
+ @curve_json.setter
534
+ def curve_json(self, json_data: str):
535
+ """
536
+ Load curves from a JSON string and add them to the plot, omitting custom source curves.
537
+ """
538
+ try:
539
+ curve_configs = json.loads(json_data)
540
+ self.clear_all()
541
+ for cfg_dict in curve_configs:
542
+ if cfg_dict.get("source") == "custom":
543
+ logger.warning(f"Custom source curve '{cfg_dict['label']}' not loaded.")
544
+ continue
545
+ config = CurveConfig(**cfg_dict)
546
+ self._add_curve(config=config)
547
+ except json.JSONDecodeError as e:
548
+ logger.error(f"Failed to decode JSON: {e}")
549
+
550
+ @property
551
+ def curves(self) -> list[Curve]:
552
+ """
553
+ Get the curves of the plot widget as a list.
554
+
555
+ Returns:
556
+ list: List of curves.
557
+ """
558
+ return [item for item in self.plot_item.curves if isinstance(item, Curve)]
559
+
560
+ ################################################################################
561
+ # High Level methods for API
562
+ ################################################################################
563
+ @SafeSlot(popup_error=True)
564
+ def plot(
565
+ self,
566
+ arg1: list | np.ndarray | str | None = None,
567
+ y: list | np.ndarray | None = None,
568
+ x: list | np.ndarray | None = None,
569
+ x_name: str | None = None,
570
+ y_name: str | None = None,
571
+ x_entry: str | None = None,
572
+ y_entry: str | None = None,
573
+ color: str | None = None,
574
+ label: str | None = None,
575
+ dap: str | None = None,
576
+ **kwargs,
577
+ ) -> Curve:
578
+ """
579
+ Plot a curve to the plot widget.
580
+
581
+ Args:
582
+ arg1(list | np.ndarray | str | None): First argument, which can be x data, y data, or y_name.
583
+ y(list | np.ndarray): Custom y data to plot.
584
+ x(list | np.ndarray): Custom y data to plot.
585
+ x_name(str): Name of the x signal.
586
+ - "auto": Use the best effort signal.
587
+ - "timestamp": Use the timestamp signal.
588
+ - "index": Use the index signal.
589
+ - Custom signal name of a device from BEC.
590
+ y_name(str): The name of the device for the y-axis.
591
+ x_entry(str): The name of the entry for the x-axis.
592
+ y_entry(str): The name of the entry for the y-axis.
593
+ color(str): The color of the curve.
594
+ label(str): The label of the curve.
595
+ dap(str): The dap model to use for the curve, only available for sync devices.
596
+ If not specified, none will be added.
597
+ Use the same string as is the name of the LMFit model.
598
+
599
+ Returns:
600
+ Curve: The curve object.
601
+ """
602
+ # 0) preallocate
603
+ source = "custom"
604
+ x_data = None
605
+ y_data = None
606
+
607
+ # 1. Custom curve logic
608
+ if x is not None and y is not None:
609
+ source = "custom"
610
+ x_data = np.asarray(x)
611
+ y_data = np.asarray(y)
612
+
613
+ if isinstance(arg1, str):
614
+ y_name = arg1
615
+ elif isinstance(arg1, list):
616
+ if isinstance(y, list):
617
+ source = "custom"
618
+ x_data = np.asarray(arg1)
619
+ y_data = np.asarray(y)
620
+ if y is None:
621
+ source = "custom"
622
+ arr = np.asarray(arg1)
623
+ x_data = np.arange(len(arr))
624
+ y_data = arr
625
+ elif isinstance(arg1, np.ndarray) and y is None:
626
+ if arg1.ndim == 1:
627
+ source = "custom"
628
+ x_data = np.arange(len(arg1))
629
+ y_data = arg1
630
+ if arg1.ndim == 2 and arg1.shape[1] == 2:
631
+ source = "custom"
632
+ x_data = arg1[:, 0]
633
+ y_data = arg1[:, 1]
634
+
635
+ # If y_name is set => device data
636
+ if y_name is not None and x_data is None and y_data is None:
637
+ source = "device"
638
+ # Validate or obtain entry
639
+ y_entry = self.entry_validator.validate_signal(name=y_name, entry=y_entry)
640
+
641
+ # If user gave x_name => store in x_axis_mode, but do not set data here
642
+ if x_name is not None:
643
+ self.x_mode = x_name
644
+ if x_name not in ["timestamp", "index", "auto"]:
645
+ self.x_axis_mode["entry"] = self.entry_validator.validate_signal(x_name, x_entry)
646
+
647
+ # Decide label if not provided
648
+ if label is None:
649
+ if source == "custom":
650
+ label = WidgetContainerUtils.generate_unique_name(
651
+ "Curve", [c.object_name for c in self.curves]
652
+ )
653
+ else:
654
+ label = f"{y_name}-{y_entry}"
655
+
656
+ # If color not provided, generate from palette
657
+ if color is None:
658
+ color = self._generate_color_from_palette()
659
+
660
+ # Build the config
661
+ config = CurveConfig(
662
+ widget_class="Curve",
663
+ parent_id=self.gui_id,
664
+ label=label,
665
+ color=color,
666
+ source=source,
667
+ **kwargs,
668
+ )
669
+
670
+ # If it's device-based, attach DeviceSignal
671
+ if source == "device":
672
+ config.signal = DeviceSignal(name=y_name, entry=y_entry)
673
+
674
+ # CREATE THE CURVE
675
+ curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
676
+
677
+ if dap is not None and source == "device":
678
+ self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
679
+
680
+ return curve
681
+
682
+ ################################################################################
683
+ # Curve Management Methods
684
+ @SafeSlot()
685
+ def add_dap_curve(
686
+ self,
687
+ device_label: str,
688
+ dap_name: str,
689
+ color: str | None = None,
690
+ dap_oversample: int = 1,
691
+ **kwargs,
692
+ ) -> Curve:
693
+ """
694
+ Create a new DAP curve referencing the existing device curve `device_label`,
695
+ with the data processing model `dap_name`.
696
+
697
+ Args:
698
+ device_label(str): The label of the device curve to add DAP to.
699
+ dap_name(str): The name of the DAP model to use.
700
+ color(str): The color of the curve.
701
+ dap_oversample(int): The oversampling factor for the DAP curve.
702
+ **kwargs
703
+
704
+ Returns:
705
+ Curve: The new DAP curve.
706
+ """
707
+
708
+ # 1) Find the existing device curve by label
709
+ device_curve = self._find_curve_by_label(device_label)
710
+ if not device_curve:
711
+ raise ValueError(f"No existing curve found with label '{device_label}'.")
712
+ if device_curve.config.source != "device":
713
+ raise ValueError(
714
+ f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
715
+ )
716
+
717
+ dev_name = device_curve.config.signal.name
718
+ dev_entry = device_curve.config.signal.entry
719
+
720
+ # 2) Build a label for the new DAP curve
721
+ dap_label = f"{dev_name}-{dev_entry}-{dap_name}"
722
+
723
+ # 3) Possibly raise if the DAP curve already exists
724
+ if self._check_curve_id(dap_label):
725
+ raise ValueError(f"DAP curve '{dap_label}' already exists.")
726
+
727
+ if color is None:
728
+ color = self._generate_color_from_palette()
729
+
730
+ # Build config for DAP
731
+ config = CurveConfig(
732
+ widget_class="Curve",
733
+ parent_id=self.gui_id,
734
+ label=dap_label,
735
+ color=color,
736
+ source="dap",
737
+ parent_label=device_label,
738
+ symbol="star",
739
+ **kwargs,
740
+ )
741
+
742
+ # Attach device signal with DAP
743
+ config.signal = DeviceSignal(
744
+ name=dev_name, entry=dev_entry, dap=dap_name, dap_oversample=dap_oversample
745
+ )
746
+
747
+ # 4) Create the DAP curve config using `_add_curve(...)`
748
+ dap_curve = self._add_curve(config=config)
749
+
750
+ return dap_curve
751
+
752
+ def _add_curve(
753
+ self,
754
+ config: CurveConfig,
755
+ x_data: np.ndarray | None = None,
756
+ y_data: np.ndarray | None = None,
757
+ ) -> Curve:
758
+ """
759
+ Private method to finalize the creation of a new Curve in this Waveform widget
760
+ based on an already-built `CurveConfig`.
761
+
762
+ Args:
763
+ config (CurveConfig): A fully populated pydantic model describing how to create and style the curve.
764
+ x_data (np.ndarray | None): If this is a custom curve (config.source == "custom"), optional x data array.
765
+ y_data (np.ndarray | None): If this is a custom curve (config.source == "custom"), optional y data array.
766
+
767
+ Returns:
768
+ Curve: The newly created curve object.
769
+
770
+ Raises:
771
+ ValueError: If a duplicate curve label/config is found, or if
772
+ custom data is missing for `source='custom'`.
773
+ """
774
+ label = config.label
775
+ if not label:
776
+ # Fallback label
777
+ label = WidgetContainerUtils.generate_unique_name(
778
+ "Curve", [c.object_name for c in self.curves]
779
+ )
780
+ config.label = label
781
+
782
+ # Check for duplicates
783
+ if self._check_curve_id(label):
784
+ raise ValueError(f"Curve with ID '{label}' already exists in widget '{self.gui_id}'.")
785
+
786
+ # If a user did not provide color in config, pick from palette
787
+ if not config.color:
788
+ config.color = self._generate_color_from_palette()
789
+
790
+ # For custom data, ensure x_data, y_data
791
+ if config.source == "custom":
792
+ if x_data is None or y_data is None:
793
+ raise ValueError("For 'custom' curves, x_data and y_data must be provided.")
794
+
795
+ # Actually create the Curve item
796
+ curve = self._add_curve_object(name=label, config=config)
797
+
798
+ # If custom => set initial data
799
+ if config.source == "custom" and x_data is not None and y_data is not None:
800
+ curve.setData(x_data, y_data)
801
+
802
+ # If device => schedule BEC updates
803
+ if config.source == "device":
804
+ if self.scan_item is None:
805
+ self.update_with_scan_history(-1)
806
+ if curve in self._async_curves:
807
+ self._setup_async_curve(curve)
808
+ self.async_signal_update.emit()
809
+ self.sync_signal_update.emit()
810
+ if config.source == "dap":
811
+ self._dap_curves.append(curve)
812
+ self.setup_dap_for_scan()
813
+ self.roi_enable.emit(True) # Enable the ROI toolbar action
814
+ self.request_dap() # Request DAP update directly without blocking proxy
815
+
816
+ return curve
817
+
818
+ def _add_curve_object(self, name: str, config: CurveConfig) -> Curve:
819
+ """
820
+ Low-level creation of the PlotDataItem (Curve) from a `CurveConfig`.
821
+
822
+ Args:
823
+ name (str): The name/label of the curve.
824
+ config (CurveConfig): Configuration model describing the curve.
825
+
826
+ Returns:
827
+ Curve: The newly created curve object, added to the plot.
828
+ """
829
+ curve = Curve(config=config, name=name, parent_item=self)
830
+ self.plot_item.addItem(curve)
831
+ self._categorise_device_curves()
832
+ return curve
833
+
834
+ def _generate_color_from_palette(self) -> str:
835
+ """
836
+ Generate a color for the next new curve, based on the current number of curves.
837
+ """
838
+ current_count = len(self.curves)
839
+ color_list = Colors.golden_angle_color(
840
+ colormap=self.config.color_palette, num=max(10, current_count + 1), format="HEX"
841
+ )
842
+ return color_list[current_count]
843
+
844
+ def _refresh_colors(self):
845
+ """
846
+ Re-assign colors to all existing curves so they match the new count-based distribution.
847
+ """
848
+ all_curves = self.curves
849
+ # Generate enough colors for the new total
850
+ color_list = Colors.golden_angle_color(
851
+ colormap=self.config.color_palette, num=max(10, len(all_curves)), format="HEX"
852
+ )
853
+ for i, curve in enumerate(all_curves):
854
+ curve.set_color(color_list[i])
855
+
856
+ def clear_data(self):
857
+ """
858
+ Clear all data from the plot widget, but keep the curve references.
859
+ """
860
+ for c in self.curves:
861
+ c.clear_data()
862
+
863
+ def clear_all(self):
864
+ """
865
+ Clear all curves from the plot widget.
866
+ """
867
+ curve_list = self.curves
868
+ self._dap_curves = []
869
+ self._sync_curves = []
870
+ self._async_curves = []
871
+ for curve in curve_list:
872
+ self.remove_curve(curve.name())
873
+ if self.crosshair is not None:
874
+ self.crosshair.clear_markers()
875
+
876
+ def get_curve(self, curve: int | str) -> Curve | None:
877
+ """
878
+ Get a curve from the plot widget.
879
+
880
+ Args:
881
+ curve(int|str): The curve to get. It Can be the order of the curve or the name of the curve.
882
+
883
+ Return(Curve|None): The curve object if found, None otherwise.
884
+ """
885
+ if isinstance(curve, int):
886
+ if curve < len(self.curves):
887
+ return self.curves[curve]
888
+ elif isinstance(curve, str):
889
+ for c in self.curves:
890
+ if c.name() == curve:
891
+ return c
892
+ return None
893
+
894
+ @SafeSlot(int, popup_error=True)
895
+ @SafeSlot(str, popup_error=True)
896
+ def remove_curve(self, curve: int | str):
897
+ """
898
+ Remove a curve from the plot widget.
899
+
900
+ Args:
901
+ curve(int|str): The curve to remove. It Can be the order of the curve or the name of the curve.
902
+ """
903
+ if isinstance(curve, int):
904
+ self._remove_curve_by_order(curve)
905
+ elif isinstance(curve, str):
906
+ self._remove_curve_by_name(curve)
907
+
908
+ self._refresh_colors()
909
+ self._categorise_device_curves()
910
+
911
+ def _remove_curve_by_name(self, name: str):
912
+ """
913
+ Remove a curve by its name from the plot widget.
914
+
915
+ Args:
916
+ name(str): Name of the curve to be removed.
917
+ """
918
+ for curve in self.curves:
919
+ if curve.name() == name:
920
+ self.plot_item.removeItem(curve)
921
+ self._curve_clean_up(curve)
922
+ return
923
+
924
+ def _remove_curve_by_order(self, N: int):
925
+ """
926
+ Remove a curve by its order from the plot widget.
927
+
928
+ Args:
929
+ N(int): Order of the curve to be removed.
930
+ """
931
+ if N < len(self.curves):
932
+ curve = self.curves[N]
933
+ self.plot_item.removeItem(curve)
934
+ self._curve_clean_up(curve)
935
+
936
+ else:
937
+ logger.error(f"Curve order {N} out of range.")
938
+ raise IndexError(f"Curve order {N} out of range.")
939
+
940
+ def _curve_clean_up(self, curve: Curve):
941
+ """
942
+ Clean up the curve by disconnecting the async update signal (even for sync curves).
943
+
944
+ Args:
945
+ curve(Curve): The curve to clean up.
946
+ """
947
+ self.bec_dispatcher.disconnect_slot(
948
+ self.on_async_readback,
949
+ MessageEndpoints.device_async_readback(self.scan_id, curve.name()),
950
+ )
951
+ curve.rpc_register.remove_rpc(curve)
952
+
953
+ # Remove itself from the DAP summary only for side panels
954
+ if (
955
+ curve.config.source == "dap"
956
+ and self.dap_summary is not None
957
+ and self.enable_side_panel is True
958
+ ):
959
+ self.dap_summary.remove_dap_data(curve.name())
960
+
961
+ # find a corresponding dap curve and remove it
962
+ for c in self.curves:
963
+ if c.config.parent_label == curve.name():
964
+ self.plot_item.removeItem(c)
965
+ self._curve_clean_up(c)
966
+
967
+ def _check_curve_id(self, curve_id: str) -> bool:
968
+ """
969
+ Check if a curve ID exists in the plot widget.
970
+
971
+ Args:
972
+ curve_id(str): The ID of the curve to check.
973
+
974
+ Returns:
975
+ bool: True if the curve ID exists, False otherwise.
976
+ """
977
+ curve_ids = [curve.name() for curve in self.curves]
978
+ if curve_id in curve_ids:
979
+ return True
980
+ return False
981
+
982
+ def _find_curve_by_label(self, label: str) -> Curve | None:
983
+ """
984
+ Find a curve by its label.
985
+
986
+ Args:
987
+ label(str): The label of the curve to find.
988
+
989
+ Returns:
990
+ Curve|None: The curve object if found, None otherwise.
991
+ """
992
+ for c in self.curves:
993
+ if c.name() == label:
994
+ return c
995
+ return None
996
+
997
+ ################################################################################
998
+ # BEC Update Methods
999
+ ################################################################################
1000
+ @SafeSlot(dict, dict)
1001
+ def on_scan_status(self, msg: dict, meta: dict):
1002
+ """
1003
+ Initial scan status message handler, which is triggered at the begging and end of scan.
1004
+ Used for triggering the update of the sync and async curves.
1005
+
1006
+ Args:
1007
+ msg(dict): The message content.
1008
+ meta(dict): The message metadata.
1009
+ """
1010
+ current_scan_id = msg.get("scan_id", None)
1011
+ if current_scan_id is None:
1012
+ return
1013
+
1014
+ if current_scan_id != self.scan_id:
1015
+ self.reset()
1016
+ self.new_scan.emit()
1017
+ self.new_scan_id.emit(current_scan_id)
1018
+ self.auto_range_x = True
1019
+ self.auto_range_y = True
1020
+ self.old_scan_id = self.scan_id
1021
+ self.scan_id = current_scan_id
1022
+ self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan
1023
+ self._slice_index = None # Reset the slice index
1024
+
1025
+ self._mode = self._categorise_device_curves()
1026
+
1027
+ # First trigger to sync and async data
1028
+ if self._mode == "sync":
1029
+ self.sync_signal_update.emit()
1030
+ logger.info("Scan status: Sync mode")
1031
+ elif self._mode == "async":
1032
+ for curve in self._async_curves:
1033
+ self._setup_async_curve(curve)
1034
+ self.async_signal_update.emit()
1035
+ logger.info("Scan status: Async mode")
1036
+ else:
1037
+ self.sync_signal_update.emit()
1038
+ for curve in self._async_curves:
1039
+ self._setup_async_curve(curve)
1040
+ self.async_signal_update.emit()
1041
+ logger.info("Scan status: Mixed mode")
1042
+ logger.warning("Mixed mode - integrity of x axis cannot be guaranteed.")
1043
+ self.setup_dap_for_scan()
1044
+
1045
+ @SafeSlot(dict, dict)
1046
+ def on_scan_progress(self, msg: dict, meta: dict):
1047
+ """
1048
+ Slot for handling scan progress messages. Used for triggering the update of the sync curves.
1049
+
1050
+ Args:
1051
+ msg(dict): The message content.
1052
+ meta(dict): The message metadata.
1053
+ """
1054
+ self.sync_signal_update.emit()
1055
+ status = msg.get("done")
1056
+ if status:
1057
+ QTimer.singleShot(100, self.update_sync_curves)
1058
+ QTimer.singleShot(300, self.update_sync_curves)
1059
+
1060
+ def _fetch_scan_data_and_access(self):
1061
+ """
1062
+ Decide whether the widget is in live or historical mode
1063
+ and return the appropriate data dict and access key.
1064
+
1065
+ Returns:
1066
+ data_dict (dict): The data structure for the current scan.
1067
+ access_key (str): Either 'val' (live) or 'value' (history).
1068
+ """
1069
+ if self.scan_item is None:
1070
+ # Optionally fetch the latest from history if nothing is set
1071
+ self.update_with_scan_history(-1)
1072
+ if self.scan_item is None:
1073
+ logger.info("No scan executed so far; skipping device curves categorisation.")
1074
+ return "none", "none"
1075
+
1076
+ if hasattr(self.scan_item, "live_data"):
1077
+ # Live scan
1078
+ return self.scan_item.live_data, "val"
1079
+ else:
1080
+ # Historical
1081
+ scan_devices = self.scan_item.devices
1082
+ return (scan_devices, "value")
1083
+
1084
+ def update_sync_curves(self):
1085
+ """
1086
+ Update the sync curves with the latest data from the scan.
1087
+ """
1088
+ if self.scan_item is None:
1089
+ logger.info("No scan executed so far; skipping device curves categorisation.")
1090
+ return "none"
1091
+ data, access_key = self._fetch_scan_data_and_access()
1092
+ for curve in self._sync_curves:
1093
+ device_name = curve.config.signal.name
1094
+ device_entry = curve.config.signal.entry
1095
+ if access_key == "val":
1096
+ device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
1097
+ else:
1098
+ device_data = (
1099
+ data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
1100
+ )
1101
+ x_data = self._get_x_data(device_name, device_entry)
1102
+ if x_data is not None:
1103
+ if len(x_data) == 1:
1104
+ self.clear_data()
1105
+ return
1106
+ if device_data is not None and x_data is not None:
1107
+ curve.setData(x_data, device_data)
1108
+ if device_data is not None and x_data is None:
1109
+ curve.setData(device_data)
1110
+ self.request_dap_update.emit()
1111
+
1112
+ def update_async_curves(self):
1113
+ """
1114
+ Updates asynchronously displayed curves with the latest scan data.
1115
+
1116
+ Fetches the scan data and access key to update each curve in `_async_curves` with
1117
+ new values. If the data is available for a specific curve, it sets the x and y
1118
+ data for the curve. Emits a signal to request an update once all curves are updated.
1119
+
1120
+ Raises:
1121
+ The raised errors are dependent on the internal methods such as
1122
+ `_fetch_scan_data_and_access`, `_get_x_data`, or `setData` used in this
1123
+ function.
1124
+
1125
+ """
1126
+ data, access_key = self._fetch_scan_data_and_access()
1127
+
1128
+ for curve in self._async_curves:
1129
+ device_name = curve.config.signal.name
1130
+ device_entry = curve.config.signal.entry
1131
+ if access_key == "val": # live access
1132
+ device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
1133
+ else: # history access
1134
+ device_data = (
1135
+ data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
1136
+ )
1137
+
1138
+ # if shape is 2D cast it into 1D and take the last waveform
1139
+ if len(np.shape(device_data)) > 1:
1140
+ device_data = device_data[-1, :]
1141
+
1142
+ if device_data is None:
1143
+ logger.warning(f"Async data for curve {curve.name()} is None.")
1144
+ continue
1145
+
1146
+ # Async curves only support plotting vs index or other device
1147
+ if self.x_axis_mode["name"] in ["timestamp", "index", "auto"]:
1148
+ device_data_x = np.linspace(0, len(device_data) - 1, len(device_data))
1149
+ else:
1150
+ # Fetch data from signal instead
1151
+ device_data_x = self._get_x_data(device_name, device_entry)
1152
+
1153
+ # Fallback to 'index' in case data is not of equal length
1154
+ if len(device_data_x) != len(device_data):
1155
+ logger.warning(
1156
+ f"Async data for curve {curve.name()} and x_axis {device_entry} is not of equal length. Falling back to 'index' plotting."
1157
+ )
1158
+ device_data_x = np.linspace(0, len(device_data) - 1, len(device_data))
1159
+
1160
+ self._auto_adjust_async_curve_settings(curve, len(device_data))
1161
+ curve.setData(device_data_x, device_data)
1162
+
1163
+ self.request_dap_update.emit()
1164
+
1165
+ def _setup_async_curve(self, curve: Curve):
1166
+ """
1167
+ Setup async curve.
1168
+
1169
+ Args:
1170
+ curve(Curve): The curve to set up.
1171
+ """
1172
+ name = curve.config.signal.name
1173
+ self.bec_dispatcher.disconnect_slot(
1174
+ self.on_async_readback, MessageEndpoints.device_async_readback(self.old_scan_id, name)
1175
+ )
1176
+ try:
1177
+ curve.clear_data()
1178
+ except KeyError:
1179
+ logger.warning(f"Curve {name} not found in plot item.")
1180
+ pass
1181
+ self.bec_dispatcher.connect_slot(
1182
+ self.on_async_readback,
1183
+ MessageEndpoints.device_async_readback(self.scan_id, name),
1184
+ from_start=True,
1185
+ )
1186
+ logger.info(f"Setup async curve {name}")
1187
+
1188
+ @SafeSlot(dict, dict)
1189
+ def on_async_readback(self, msg, metadata):
1190
+ """
1191
+ Get async data readback. This code needs to be fast, therefor we try
1192
+ to reduce the number of copies in between cycles. Be careful when refactoring
1193
+ this part as it will affect the performance of the async readback.
1194
+
1195
+ Async curves support plotting against 'index' or other 'device_signal'. No 'auto' or 'timestamp'.
1196
+ The fallback mechanism for 'auto' and 'timestamp' is to use the 'index'.
1197
+
1198
+ Note:
1199
+ We create data_plot_x and data_plot_y and modify them within this function
1200
+ to avoid creating new arrays. This is important for performance.
1201
+ Support update instructions are 'add', 'add_slice', and 'replace'.
1202
+
1203
+ Args:
1204
+ msg(dict): Message with the async data.
1205
+ metadata(dict): Metadata of the message.
1206
+ """
1207
+ instruction = metadata.get("async_update", {}).get("type")
1208
+ if instruction not in ["add", "add_slice", "replace"]:
1209
+ logger.warning(f"Invalid async update instruction: {instruction}")
1210
+ return
1211
+ max_shape = metadata.get("async_update", {}).get("max_shape", [])
1212
+ plot_mode = self.x_axis_mode["name"]
1213
+ for curve in self._async_curves:
1214
+ x_data = None # Reset x_data
1215
+ # Get the curve data
1216
+ async_data = msg["signals"].get(curve.config.signal.entry, None)
1217
+ if async_data is None:
1218
+ continue
1219
+ # y-data
1220
+ data_plot_y = async_data["value"]
1221
+ if data_plot_y is None:
1222
+ logger.warning(f"Async data for curve {curve.name()} is None.")
1223
+ continue
1224
+ # Ensure we have numpy array for data_plot_y
1225
+ data_plot_y = np.asarray(data_plot_y)
1226
+ # Add
1227
+ if instruction == "add":
1228
+ if len(max_shape) > 1:
1229
+ if len(data_plot_y.shape) > 1:
1230
+ data_plot_y = data_plot_y[-1, :]
1231
+ else:
1232
+ x_data, y_data = curve.get_data()
1233
+ if y_data is not None:
1234
+ data_plot_y = np.hstack((y_data, data_plot_y))
1235
+ # Add slice
1236
+ if instruction == "add_slice":
1237
+ current_slice_id = metadata.get("async_update", {}).get("index")
1238
+ if current_slice_id != curve.slice_index:
1239
+ curve.slice_index = current_slice_id
1240
+ else:
1241
+ x_data, y_data = curve.get_data()
1242
+ if y_data is not None:
1243
+ data_plot_y = np.hstack((y_data, data_plot_y))
1244
+
1245
+ # Replace is trivial, no need to modify data_plot_y
1246
+
1247
+ # Get x data for plotting
1248
+ if plot_mode in ["index", "auto", "timestamp"]:
1249
+ data_plot_x = np.linspace(0, len(data_plot_y) - 1, len(data_plot_y))
1250
+ self._auto_adjust_async_curve_settings(curve, len(data_plot_y))
1251
+ curve.setData(data_plot_x, data_plot_y)
1252
+ # Move on in the loop
1253
+ continue
1254
+
1255
+ # x_axis_mode is device signal
1256
+ # Only consider device signals that are async for now, fallback is index
1257
+ x_device_entry = self.x_axis_mode["entry"]
1258
+ async_data = msg["signals"].get(x_device_entry, None)
1259
+ # Make sure the signal exists, otherwise fall back to index
1260
+ if async_data is None:
1261
+ # Try to grab the data from device signals
1262
+ data_plot_x = self._get_x_data(plot_mode, x_device_entry)
1263
+ else:
1264
+ data_plot_x = np.asarray(async_data["value"])
1265
+ if x_data is not None:
1266
+ data_plot_x = np.hstack((x_data, data_plot_x))
1267
+ # Fallback incase data is not of equal length
1268
+ if len(data_plot_x) != len(data_plot_y):
1269
+ logger.warning(
1270
+ f"Async data for curve {curve.name()} and x_axis {x_device_entry} is not of equal length. Falling back to 'index' plotting."
1271
+ )
1272
+ data_plot_x = np.linspace(0, len(data_plot_y) - 1, len(data_plot_y))
1273
+
1274
+ # Plot the data
1275
+ self._auto_adjust_async_curve_settings(curve, len(data_plot_y))
1276
+ curve.setData(data_plot_x, data_plot_y)
1277
+
1278
+ self.request_dap_update.emit()
1279
+
1280
+ def _auto_adjust_async_curve_settings(
1281
+ self,
1282
+ curve: Curve,
1283
+ data_length: int,
1284
+ limit: int = 1000,
1285
+ method: Literal["subsample", "mean", "peak"] | None = "peak",
1286
+ ) -> None:
1287
+ """
1288
+ Based on the length of the data this method will adjust the plotting settings of
1289
+ Curve items, by deactivating the symbol and activating downsampling auto, method='mean',
1290
+ if the data length exceeds N points. If the data length is less than N points, the
1291
+ symbol will be activated and downsampling will be deactivated. Maximum points will be
1292
+ 5x the limit.
1293
+
1294
+ Args:
1295
+ curve(Curve): The curve to adjust.
1296
+ data_length(int): The length of the data.
1297
+ limit(int): The limit of the data length to activate the downsampling.
1298
+
1299
+ """
1300
+ if limit <= 1:
1301
+ logger.warning("Limit must be greater than 1.")
1302
+ return
1303
+ if data_length > limit:
1304
+ if curve.config.symbol is not None:
1305
+ curve.set_symbol(None)
1306
+ if curve.config.pen_width > 3:
1307
+ curve.set_pen_width(3)
1308
+ curve.setDownsampling(ds=None, auto=True, method=method)
1309
+ curve.setClipToView(True)
1310
+ elif data_length <= limit:
1311
+ curve.set_symbol("o")
1312
+ curve.set_pen_width(4)
1313
+ curve.setDownsampling(ds=1, auto=None, method=method)
1314
+ curve.setClipToView(True)
1315
+
1316
+ def setup_dap_for_scan(self):
1317
+ """Setup DAP updates for the new scan."""
1318
+ self.bec_dispatcher.disconnect_slot(
1319
+ self.update_dap_curves,
1320
+ MessageEndpoints.dap_response(f"{self.old_scan_id}-{self.gui_id}"),
1321
+ )
1322
+ if len(self._dap_curves) > 0:
1323
+ self.bec_dispatcher.connect_slot(
1324
+ self.update_dap_curves,
1325
+ MessageEndpoints.dap_response(f"{self.scan_id}-{self.gui_id}"),
1326
+ )
1327
+
1328
+ @SafeSlot()
1329
+ def request_dap(self, _=None):
1330
+ """Request new fit for data"""
1331
+
1332
+ for dap_curve in self._dap_curves:
1333
+ parent_label = getattr(dap_curve.config, "parent_label", None)
1334
+ if not parent_label:
1335
+ continue
1336
+ # find the device curve
1337
+ parent_curve = self._find_curve_by_label(parent_label)
1338
+ if parent_curve is None:
1339
+ logger.warning(
1340
+ f"No device curve found for DAP curve '{dap_curve.name()}'!"
1341
+ ) # TODO triggerd when DAP curve is removed from the curve dialog, why?
1342
+ continue
1343
+
1344
+ x_data, y_data = parent_curve.get_data()
1345
+ model_name = dap_curve.config.signal.dap
1346
+ model = getattr(self.dap, model_name)
1347
+ try:
1348
+ x_min, x_max = self.roi_region
1349
+ x_data, y_data = self._crop_data(x_data, y_data, x_min, x_max)
1350
+ except TypeError:
1351
+ x_min = None
1352
+ x_max = None
1353
+
1354
+ msg = messages.DAPRequestMessage(
1355
+ dap_cls="LmfitService1D",
1356
+ dap_type="on_demand",
1357
+ config={
1358
+ "args": [],
1359
+ "kwargs": {"data_x": x_data, "data_y": y_data},
1360
+ "class_args": model._plugin_info["class_args"],
1361
+ "class_kwargs": model._plugin_info["class_kwargs"],
1362
+ "curve_label": dap_curve.name(),
1363
+ },
1364
+ metadata={"RID": f"{self.scan_id}-{self.gui_id}"},
1365
+ )
1366
+ self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg)
1367
+
1368
+ @SafeSlot(dict, dict)
1369
+ def update_dap_curves(self, msg, metadata):
1370
+ """
1371
+ Update the DAP curves with the new data.
1372
+
1373
+ Args:
1374
+ msg(dict): Message with the DAP data.
1375
+ metadata(dict): Metadata of the message.
1376
+ """
1377
+ self.unblock_dap_proxy.emit()
1378
+ # Extract configuration from the message
1379
+ msg_config = msg.get("dap_request", None).content.get("config", {})
1380
+ curve_id = msg_config.get("curve_label", None)
1381
+ curve = self._find_curve_by_label(curve_id)
1382
+ if not curve:
1383
+ return
1384
+
1385
+ # Get data from the parent (device) curve
1386
+ parent_curve = self._find_curve_by_label(curve.config.parent_label)
1387
+ if parent_curve is None:
1388
+ return
1389
+ x_parent, _ = parent_curve.get_data()
1390
+ if x_parent is None or len(x_parent) == 0:
1391
+ return
1392
+
1393
+ # Retrieve and store the fit parameters and summary from the DAP server response
1394
+ try:
1395
+ curve.dap_params = msg["data"][1]["fit_parameters"]
1396
+ curve.dap_summary = msg["data"][1]["fit_summary"]
1397
+ except TypeError:
1398
+ logger.warning(f"Failed to retrieve DAP data for curve '{curve.name()}'")
1399
+ return
1400
+
1401
+ # Render model according to the DAP model name and parameters
1402
+ model_name = curve.config.signal.dap
1403
+ model_function = getattr(lmfit.models, model_name)()
1404
+
1405
+ x_min, x_max = x_parent.min(), x_parent.max()
1406
+ oversample = curve.dap_oversample
1407
+ new_x = np.linspace(x_min, x_max, int(len(x_parent) * oversample))
1408
+
1409
+ # Evaluate the model with the provided parameters to generate the y values
1410
+ new_y = model_function.eval(**curve.dap_params, x=new_x)
1411
+
1412
+ # Update the curve with the new data
1413
+ curve.setData(new_x, new_y)
1414
+
1415
+ metadata.update({"curve_id": curve_id})
1416
+ self.dap_params_update.emit(curve.dap_params, metadata)
1417
+ self.dap_summary_update.emit(curve.dap_summary, metadata)
1418
+
1419
+ def _refresh_dap_signals(self):
1420
+ """
1421
+ Refresh the DAP signals for all curves.
1422
+ """
1423
+ for curve in self._dap_curves:
1424
+ self.dap_params_update.emit(curve.dap_params, {"curve_id": curve.name()})
1425
+ self.dap_summary_update.emit(curve.dap_summary, {"curve_id": curve.name()})
1426
+
1427
+ def _get_x_data(self, device_name: str, device_entry: str) -> list | np.ndarray | None:
1428
+ """
1429
+ Get the x data for the curves with the decision logic based on the widget x mode configuration:
1430
+ - If x is called 'timestamp', use the timestamp data from the scan item.
1431
+ - If x is called 'index', use the rolling index.
1432
+ - If x is a custom signal, use the data from the scan item.
1433
+ - If x is not specified, use the first device from the scan report.
1434
+
1435
+ Additionally, checks and updates the x label suffix.
1436
+
1437
+ Args:
1438
+ device_name(str): The name of the device.
1439
+ device_entry(str): The entry of the device
1440
+
1441
+ Returns:
1442
+ list|np.ndarray|None: X data for the curve.
1443
+ """
1444
+ x_data = None
1445
+ new_suffix = None
1446
+ data, access_key = self._fetch_scan_data_and_access()
1447
+
1448
+ # 1 User wants custom signal
1449
+ if self.x_axis_mode["name"] not in ["timestamp", "index", "auto"]:
1450
+ x_name = self.x_axis_mode["name"]
1451
+ x_entry = self.x_axis_mode.get("entry", None)
1452
+ if x_entry is None:
1453
+ x_entry = self.entry_validator.validate_signal(x_name, None)
1454
+ # if the motor was not scanned, an empty list is returned and curves are not updated
1455
+ if access_key == "val": # live data
1456
+ x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, [0])
1457
+ else: # history data
1458
+ x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", [0])
1459
+ new_suffix = f" [custom: {x_name}-{x_entry}]"
1460
+
1461
+ # 2 User wants timestamp
1462
+ if self.x_axis_mode["name"] == "timestamp":
1463
+ if access_key == "val": # live
1464
+ timestamps = data[device_name][device_entry].timestamps
1465
+ else: # history data
1466
+ timestamps = data[device_name][device_entry].read().get("timestamp", [0])
1467
+ x_data = timestamps
1468
+ new_suffix = " [timestamp]"
1469
+
1470
+ # 3 User wants index
1471
+ if self.x_axis_mode["name"] == "index":
1472
+ x_data = None
1473
+ new_suffix = " [index]"
1474
+
1475
+ # 4 Best effort automatic mode
1476
+ if self.x_axis_mode["name"] is None or self.x_axis_mode["name"] == "auto":
1477
+ # 4.1 If there are async curves, use index
1478
+ if len(self._async_curves) > 0:
1479
+ x_data = None
1480
+ new_suffix = " [auto: index]"
1481
+ # 4.2 If there are sync curves, use the first device from the scan report
1482
+ else:
1483
+ try:
1484
+ x_name = self._ensure_str_list(
1485
+ self.scan_item.metadata["bec"]["scan_report_devices"]
1486
+ )[0]
1487
+ except:
1488
+ x_name = self.scan_item.status_message.info["scan_report_devices"][0]
1489
+ x_entry = self.entry_validator.validate_signal(x_name, None)
1490
+ if access_key == "val":
1491
+ x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
1492
+ else:
1493
+ x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
1494
+ new_suffix = f" [auto: {x_name}-{x_entry}]"
1495
+ self._update_x_label_suffix(new_suffix)
1496
+ return x_data
1497
+
1498
+ def _update_x_label_suffix(self, new_suffix: str):
1499
+ """
1500
+ Update x_label so it ends with `new_suffix`, removing any old suffix.
1501
+
1502
+ Args:
1503
+ new_suffix(str): The new suffix to add to the x_label.
1504
+ """
1505
+ if new_suffix == self.x_axis_mode["label_suffix"]:
1506
+ return
1507
+
1508
+ self.x_axis_mode["label_suffix"] = new_suffix
1509
+ self.set_x_label_suffix(new_suffix)
1510
+
1511
+ def _switch_x_axis_item(self, mode: str):
1512
+ """
1513
+ Switch the x-axis mode between timestamp, index, the best effort and custom signal.
1514
+
1515
+ Args:
1516
+ mode(str): Mode of the x-axis.
1517
+ - "timestamp": Use the timestamp signal.
1518
+ - "index": Use the index signal.
1519
+ - "best_effort": Use the best effort signal.
1520
+ - Custom signal name of a device from BEC.
1521
+ """
1522
+ logger.info(f'Switching x-axis mode to "{mode}"')
1523
+ current_axis = self.plot_item.axes["bottom"]["item"]
1524
+ # Only update the axis if the mode change requires it.
1525
+ if mode == "timestamp":
1526
+ # Only update if the current axis is not a DateAxisItem.
1527
+ if not isinstance(current_axis, pg.graphicsItems.DateAxisItem.DateAxisItem):
1528
+ date_axis = pg.graphicsItems.DateAxisItem.DateAxisItem(orientation="bottom")
1529
+ self.plot_item.setAxisItems({"bottom": date_axis})
1530
+ else:
1531
+ # For non-timestamp modes, only update if the current axis is a DateAxisItem.
1532
+ if isinstance(current_axis, pg.graphicsItems.DateAxisItem.DateAxisItem):
1533
+ default_axis = pg.AxisItem(orientation="bottom")
1534
+ self.plot_item.setAxisItems({"bottom": default_axis})
1535
+
1536
+ self.set_x_label_suffix(self.x_axis_mode["label_suffix"])
1537
+
1538
+ def _categorise_device_curves(self) -> str:
1539
+ """
1540
+ Categorise the device curves into sync and async based on the readout priority.
1541
+ """
1542
+ if self.scan_item is None:
1543
+ self.update_with_scan_history(-1)
1544
+ if self.scan_item is None:
1545
+ logger.info("No scan executed so far; skipping device curves categorisation.")
1546
+ return "none"
1547
+
1548
+ if hasattr(self.scan_item, "live_data"):
1549
+ readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
1550
+ else:
1551
+ readout_priority = self.scan_item.metadata["bec"]["readout_priority"] # history
1552
+
1553
+ # Reset sync/async curve lists
1554
+ self._async_curves.clear()
1555
+ self._sync_curves.clear()
1556
+ found_async = False
1557
+ found_sync = False
1558
+ mode = "sync"
1559
+
1560
+ readout_priority_async = self._ensure_str_list(readout_priority.get("async", []))
1561
+ readout_priority_sync = self._ensure_str_list(readout_priority.get("monitored", []))
1562
+
1563
+ # Iterate over all curves
1564
+ for curve in self.curves:
1565
+ if curve.config.source != "device":
1566
+ continue
1567
+ dev_name = curve.config.signal.name
1568
+ if dev_name in readout_priority_async:
1569
+ self._async_curves.append(curve)
1570
+ found_async = True
1571
+ elif dev_name in readout_priority_sync:
1572
+ self._sync_curves.append(curve)
1573
+ found_sync = True
1574
+ else:
1575
+ logger.warning("Device {dev_name} not found in readout priority list.")
1576
+ # Determine the mode of the scan
1577
+ if found_async and found_sync:
1578
+ mode = "mixed"
1579
+ logger.warning(
1580
+ f"Found both async and sync devices in the scan. X-axis integrity cannot be guaranteed."
1581
+ )
1582
+ elif found_async:
1583
+ mode = "async"
1584
+ elif found_sync:
1585
+ mode = "sync"
1586
+
1587
+ logger.info(f"Scan {self.scan_id} => mode={self._mode}")
1588
+ return mode
1589
+
1590
+ @SafeSlot(int)
1591
+ @SafeSlot(str)
1592
+ @SafeSlot()
1593
+ def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
1594
+ """
1595
+ Update the scan curves with the data from the scan storage.
1596
+ Provide only one of scan_id or scan_index.
1597
+
1598
+ Args:
1599
+ scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
1600
+ scan_index(int, optional): Index of the scan to be updated. Defaults to None.
1601
+ """
1602
+ if scan_index is not None and scan_id is not None:
1603
+ raise ValueError("Only one of scan_id or scan_index can be provided.")
1604
+
1605
+ if scan_index is None and scan_id is None:
1606
+ logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
1607
+ scan_index = -1
1608
+
1609
+ if scan_index is None:
1610
+ self.scan_id = scan_id
1611
+ self.scan_item = self.client.history.get_by_scan_id(scan_id)
1612
+ self._emit_signal_update()
1613
+ return
1614
+
1615
+ if scan_index == -1:
1616
+ scan_item = self.client.queue.scan_storage.current_scan
1617
+ if scan_item is not None:
1618
+ if scan_item.status_message is None:
1619
+ logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
1620
+ return
1621
+ self.scan_item = scan_item
1622
+ self.scan_id = scan_item.scan_id
1623
+ self._emit_signal_update()
1624
+ return
1625
+
1626
+ if len(self.client.history) == 0:
1627
+ logger.info("No scans executed so far. Skipping scan history update.")
1628
+ return
1629
+
1630
+ self.scan_item = self.client.history[scan_index]
1631
+ metadata = self.scan_item.metadata
1632
+ self.scan_id = metadata["bec"]["scan_id"]
1633
+
1634
+ self._emit_signal_update()
1635
+
1636
+ def _emit_signal_update(self):
1637
+ self._categorise_device_curves()
1638
+
1639
+ self.setup_dap_for_scan()
1640
+ self.sync_signal_update.emit()
1641
+ self.async_signal_update.emit()
1642
+
1643
+ ################################################################################
1644
+ # Utility Methods
1645
+ ################################################################################
1646
+ def _ensure_str_list(self, entries: list | tuple | np.ndarray):
1647
+ """
1648
+ Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either)
1649
+ into a list of Python strings.
1650
+
1651
+ Args:
1652
+ entries:
1653
+
1654
+ Returns:
1655
+ list[str]: A list of Python strings.
1656
+ """
1657
+
1658
+ if isinstance(entries, (list, tuple, np.ndarray)):
1659
+ return [self._to_str(e) for e in entries]
1660
+ else:
1661
+ return [self._to_str(entries)]
1662
+
1663
+ @staticmethod
1664
+ def _to_str(x):
1665
+ """
1666
+ Convert a single object x (which may be a Python string, bytes, or something else)
1667
+ into a plain Python string.
1668
+ """
1669
+ if isinstance(x, bytes):
1670
+ return x.decode("utf-8", errors="replace")
1671
+ return str(x)
1672
+
1673
+ @staticmethod
1674
+ def _crop_data(x_data, y_data, x_min=None, x_max=None):
1675
+ """
1676
+ Utility function to crop x_data and y_data based on x_min and x_max.
1677
+
1678
+ Args:
1679
+ x_data (np.ndarray): The array of x-values.
1680
+ y_data (np.ndarray): The array of y-values corresponding to x_data.
1681
+ x_min (float, optional): The lower bound for cropping. Defaults to None.
1682
+ x_max (float, optional): The upper bound for cropping. Defaults to None.
1683
+
1684
+ Returns:
1685
+ tuple: (cropped_x_data, cropped_y_data)
1686
+ """
1687
+ # If either bound is None, skip cropping
1688
+ if x_min is None or x_max is None:
1689
+ return x_data, y_data
1690
+
1691
+ # Create a boolean mask to select only those points within [x_min, x_max]
1692
+ mask = (x_data >= x_min) & (x_data <= x_max)
1693
+
1694
+ return x_data[mask], y_data[mask]
1695
+
1696
+ ################################################################################
1697
+ # Export Methods
1698
+ ################################################################################
1699
+ def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict: # | pd.DataFrame:
1700
+ """
1701
+ Extract all curve data into a dictionary or a pandas DataFrame.
1702
+
1703
+ Args:
1704
+ output (Literal["dict", "pandas"]): Format of the output data.
1705
+
1706
+ Returns:
1707
+ dict | pd.DataFrame: Data of all curves in the specified format.
1708
+ """
1709
+ data = {}
1710
+ if output == "pandas": # pragma: no cover
1711
+ try:
1712
+ import pandas as pd
1713
+ except ModuleNotFoundError:
1714
+ raise ModuleNotFoundError(
1715
+ "Pandas is not installed. Please install pandas using 'pip install pandas'."
1716
+ )
1717
+
1718
+ for curve in self.curves:
1719
+ x_data, y_data = curve.get_data()
1720
+ if x_data is not None or y_data is not None:
1721
+ if output == "dict":
1722
+ data[curve.name()] = {"x": x_data.tolist(), "y": y_data.tolist()}
1723
+ elif output == "pandas" and pd is not None:
1724
+ data[curve.name()] = pd.DataFrame({"x": x_data, "y": y_data})
1725
+
1726
+ if output == "pandas" and pd is not None: # pragma: no cover
1727
+ combined_data = pd.concat(
1728
+ [data[curve.name()] for curve in self.curves],
1729
+ axis=1,
1730
+ keys=[curve.name() for curve in self.curves],
1731
+ )
1732
+ return combined_data
1733
+ return data
1734
+
1735
+ def export_to_matplotlib(self): # pragma: no cover
1736
+ """
1737
+ Export current waveform to matplotlib gui. Available only if matplotlib is installed in the environment.
1738
+
1739
+ """
1740
+ try:
1741
+ import matplotlib as mpl
1742
+ from pyqtgraph.exporters import MatplotlibExporter
1743
+
1744
+ MatplotlibExporter(self.plot_item).export()
1745
+ except ModuleNotFoundError:
1746
+ logger.error("Matplotlib is not installed in the environment.")
1747
+
1748
+ ################################################################################
1749
+ # Cleanup
1750
+ ################################################################################
1751
+ def cleanup(self):
1752
+ """
1753
+ Cleanup the widget by disconnecting signals and closing dialogs.
1754
+ """
1755
+ self.proxy_dap_request.cleanup()
1756
+ self.clear_all()
1757
+ if self.curve_settings_dialog is not None:
1758
+ self.curve_settings_dialog.reject()
1759
+ self.curve_settings_dialog = None
1760
+ if self.dap_summary_dialog is not None:
1761
+ self.dap_summary_dialog.reject()
1762
+ self.dap_summary_dialog = None
1763
+ super().cleanup()
1764
+
1765
+
1766
+ class DemoApp(QMainWindow): # pragma: no cover
1767
+ def __init__(self):
1768
+ super().__init__()
1769
+ self.setWindowTitle("Waveform Demo")
1770
+ self.resize(800, 600)
1771
+ self.main_widget = QWidget(self)
1772
+ self.layout = QHBoxLayout(self.main_widget)
1773
+ self.setCentralWidget(self.main_widget)
1774
+
1775
+ self.waveform_popup = Waveform(popups=True)
1776
+ self.waveform_popup.plot(y_name="monitor_async")
1777
+
1778
+ self.waveform_side = Waveform(popups=False)
1779
+ self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
1780
+ self.waveform_side.plot(y_name="bpm3a", y_entry="bpm3a")
1781
+
1782
+ self.layout.addWidget(self.waveform_side)
1783
+ self.layout.addWidget(self.waveform_popup)
1784
+
1785
+
1786
+ if __name__ == "__main__": # pragma: no cover
1787
+ import sys
1788
+
1789
+ app = QApplication(sys.argv)
1790
+ set_theme("dark")
1791
+ widget = DemoApp()
1792
+ widget.show()
1793
+ widget.resize(1400, 600)
1794
+ sys.exit(app.exec_())