bec-widgets 1.25.1__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.
- .gitlab-ci.yml +3 -5
- CHANGELOG.md +631 -0
- PKG-INFO +3 -3
- bec_widgets/__init__.py +4 -0
- bec_widgets/applications/bw_launch.py +23 -0
- bec_widgets/applications/launch_window.py +430 -0
- bec_widgets/assets/app_icons/auto_update.png +0 -0
- bec_widgets/assets/app_icons/ui_loader_tile.png +0 -0
- bec_widgets/cli/__init__.py +0 -1
- bec_widgets/cli/client.py +1779 -2064
- bec_widgets/cli/client_utils.py +346 -174
- bec_widgets/cli/generate_cli.py +143 -37
- bec_widgets/cli/rpc/rpc_base.py +152 -21
- bec_widgets/cli/rpc/rpc_register.py +113 -6
- bec_widgets/cli/rpc/rpc_widget_handler.py +13 -11
- bec_widgets/cli/server.py +125 -239
- bec_widgets/examples/jupyter_console/jupyter_console_window.py +97 -145
- bec_widgets/examples/plugin_example_pyside/tictactoetaskmenu.py +1 -1
- bec_widgets/utils/bec_connector.py +190 -21
- bec_widgets/utils/bec_designer.py +7 -0
- bec_widgets/utils/bec_dispatcher.py +71 -4
- bec_widgets/utils/bec_plugin_helper.py +89 -0
- bec_widgets/utils/bec_signal_proxy.py +1 -1
- bec_widgets/utils/bec_widget.py +26 -10
- bec_widgets/utils/colors.py +1 -1
- bec_widgets/{qt_utils → utils}/compact_popup.py +2 -0
- bec_widgets/utils/container_utils.py +37 -12
- bec_widgets/utils/crosshair.py +25 -8
- bec_widgets/utils/entry_validator.py +3 -1
- bec_widgets/{qt_utils → utils}/error_popups.py +18 -0
- bec_widgets/{qt_utils → utils}/expandable_frame.py +2 -2
- bec_widgets/utils/forms_from_types/forms.py +182 -0
- bec_widgets/{widgets/editors/scan_metadata/_metadata_widgets.py → utils/forms_from_types/items.py} +41 -30
- bec_widgets/utils/generate_designer_plugin.py +40 -36
- bec_widgets/utils/linear_region_selector.py +2 -0
- bec_widgets/utils/name_utils.py +16 -0
- bec_widgets/{qt_utils → utils}/palette_viewer.py +2 -2
- bec_widgets/utils/plot_indicator_items.py +2 -5
- bec_widgets/utils/plugin_utils.py +47 -1
- bec_widgets/{qt_utils → utils}/round_frame.py +14 -14
- bec_widgets/utils/rpc_server.py +277 -0
- bec_widgets/utils/serialization.py +44 -0
- bec_widgets/{qt_utils → utils}/settings_dialog.py +26 -1
- bec_widgets/{qt_utils → utils}/side_panel.py +17 -10
- bec_widgets/{qt_utils → utils}/toolbar.py +69 -25
- bec_widgets/utils/ui_loader.py +8 -8
- bec_widgets/utils/widget_io.py +166 -25
- bec_widgets/widgets/containers/auto_update/auto_updates.py +364 -0
- bec_widgets/widgets/containers/dock/dock.py +157 -49
- bec_widgets/widgets/containers/dock/dock_area.py +186 -138
- bec_widgets/widgets/containers/layout_manager/layout_manager.py +2 -1
- bec_widgets/widgets/containers/main_window/addons/web_links.py +15 -0
- bec_widgets/widgets/containers/main_window/main_window.py +189 -41
- bec_widgets/widgets/control/buttons/button_abort/button_abort.py +3 -4
- bec_widgets/widgets/control/buttons/button_reset/button_reset.py +3 -4
- bec_widgets/widgets/control/buttons/button_resume/button_resume.py +3 -3
- bec_widgets/widgets/control/buttons/stop_button/stop_button.py +18 -7
- bec_widgets/widgets/control/device_control/position_indicator/position_indicator.py +22 -3
- bec_widgets/widgets/control/device_control/positioner_box/_base/positioner_box_base.py +31 -13
- bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +3 -1
- bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.ui +27 -4
- bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +5 -2
- bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.ui +97 -31
- bec_widgets/widgets/control/device_control/positioner_box/positioner_control_line/positioner_control_line.ui +11 -4
- bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py +2 -3
- bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +29 -4
- bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +1 -0
- bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +2 -2
- bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +2 -2
- bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +1 -2
- bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +1 -2
- bec_widgets/widgets/control/scan_control/scan_control.py +7 -5
- bec_widgets/widgets/control/scan_control/scan_group_box.py +28 -5
- bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +1 -2
- bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py +3 -4
- bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_vertical.ui +14 -8
- bec_widgets/widgets/editors/console/console.py +1 -1
- bec_widgets/widgets/editors/{scan_metadata/additional_metadata_table.py → dict_backed_table.py} +29 -6
- bec_widgets/widgets/editors/scan_metadata/__init__.py +0 -7
- bec_widgets/widgets/editors/scan_metadata/_util.py +1 -1
- bec_widgets/widgets/{plots/motor_map/register_bec_motor_map_widget.py → editors/scan_metadata/register_scan_metadata.py} +2 -4
- bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +42 -136
- bec_widgets/widgets/editors/scan_metadata/scan_metadata.pyproject +1 -0
- bec_widgets/widgets/{plots/multi_waveform/bec_multi_waveform_widget_plugin.py → editors/scan_metadata/scan_metadata_plugin.py} +9 -9
- bec_widgets/widgets/editors/text_box/text_box.py +2 -3
- bec_widgets/widgets/editors/website/website.py +2 -2
- bec_widgets/widgets/games/minesweeper.py +3 -2
- bec_widgets/widgets/plots/image/image.py +960 -0
- bec_widgets/widgets/plots/image/image.pyproject +1 -0
- bec_widgets/widgets/plots/image/image_item.py +279 -0
- bec_widgets/widgets/plots/{motor_map/bec_motor_map_widget_plugin.py → image/image_plugin.py} +11 -13
- bec_widgets/widgets/{containers/figure/plots → plots}/image/image_processor.py +31 -64
- bec_widgets/widgets/plots/image/{register_bec_image_widget.py → register_image.py} +2 -2
- bec_widgets/widgets/plots/image/toolbar_bundles/image_selection.py +59 -0
- bec_widgets/widgets/plots/image/toolbar_bundles/processing.py +79 -0
- bec_widgets/widgets/plots/motor_map/motor_map.py +832 -0
- bec_widgets/widgets/plots/motor_map/motor_map.pyproject +1 -0
- bec_widgets/widgets/plots/motor_map/motor_map_plugin.py +54 -0
- bec_widgets/widgets/plots/{multi_waveform/register_bec_multi_waveform_widget.py → motor_map/register_motor_map.py} +2 -4
- bec_widgets/widgets/plots/motor_map/settings/motor_map_settings.py +129 -0
- bec_widgets/widgets/plots/motor_map/settings/motor_map_settings.ui +120 -0
- bec_widgets/widgets/plots/motor_map/toolbar_bundles/motor_selection.py +70 -0
- bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +508 -0
- bec_widgets/widgets/plots/multi_waveform/multi_waveform.pyproject +1 -0
- bec_widgets/widgets/plots/multi_waveform/multi_waveform_plugin.py +54 -0
- bec_widgets/widgets/plots/multi_waveform/register_multi_waveform.py +15 -0
- bec_widgets/widgets/plots/multi_waveform/settings/control_panel.py +144 -0
- bec_widgets/widgets/plots/multi_waveform/settings/multi_waveform_controls.ui +164 -0
- bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py +65 -0
- bec_widgets/widgets/{plots_next_gen → plots}/plot_base.py +321 -40
- bec_widgets/widgets/plots/{waveform/register_bec_waveform_widget.py → scatter_waveform/register_scatter_waveform.py} +3 -3
- bec_widgets/widgets/plots/scatter_waveform/scatter_curve.py +197 -0
- bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +553 -0
- bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.pyproject +1 -0
- bec_widgets/widgets/plots/{image/bec_image_widget_plugin.py → scatter_waveform/scatter_waveform_plugin.py} +9 -13
- bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_setting.py +138 -0
- bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_horizontal.ui +195 -0
- bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_vertical.ui +204 -0
- bec_widgets/widgets/{plots_next_gen → plots}/setting_menus/axis_settings.py +8 -8
- bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/mouse_interactions.py +4 -18
- bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/plot_export.py +14 -3
- bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/roi_bundle.py +6 -1
- bec_widgets/widgets/{plots_next_gen → plots}/toolbar_bundles/save_state.py +2 -2
- bec_widgets/widgets/{containers/figure/plots/waveform/waveform_curve.py → plots/waveform/curve.py} +119 -49
- bec_widgets/widgets/plots/waveform/register_waveform.py +15 -0
- bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_setting.py +125 -0
- bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +576 -0
- bec_widgets/widgets/plots/waveform/utils/__init__.py +0 -0
- bec_widgets/widgets/plots/waveform/utils/roi_manager.py +84 -0
- bec_widgets/widgets/plots/waveform/waveform.py +1794 -0
- bec_widgets/widgets/plots/waveform/waveform.pyproject +1 -0
- bec_widgets/widgets/plots/waveform/{bec_waveform_widget_plugin.py → waveform_plugin.py} +9 -13
- bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +1 -2
- bec_widgets/widgets/progress/ring_progress_bar/ring.py +11 -10
- bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +24 -14
- bec_widgets/widgets/services/bec_queue/bec_queue.py +13 -11
- bec_widgets/widgets/services/bec_status_box/bec_status_box.py +3 -4
- bec_widgets/widgets/services/device_browser/device_browser.py +5 -2
- bec_widgets/widgets/services/device_browser/device_item/device_item.py +1 -1
- bec_widgets/widgets/utility/logpanel/logpanel.py +36 -17
- bec_widgets/widgets/utility/spinbox/decimal_spinbox.py +3 -3
- bec_widgets/widgets/utility/visual/color_button/color_button.py +1 -1
- bec_widgets/widgets/utility/visual/colormap_widget/colormap_widget.py +4 -6
- bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py +4 -8
- {bec_widgets-1.25.1.dist-info → bec_widgets-2.0.0.dist-info}/METADATA +3 -3
- {bec_widgets-1.25.1.dist-info → bec_widgets-2.0.0.dist-info}/RECORD +168 -153
- pyproject.toml +3 -3
- bec_widgets/applications/alignment/alignment_1d/alignment_1d.py +0 -198
- bec_widgets/applications/alignment/alignment_1d/alignment_1d.ui +0 -615
- bec_widgets/applications/bec_app.py +0 -84
- bec_widgets/cli/auto_updates.py +0 -168
- bec_widgets/widgets/containers/figure/__init__.py +0 -1
- bec_widgets/widgets/containers/figure/figure.py +0 -796
- bec_widgets/widgets/containers/figure/plots/axis_settings.py +0 -91
- bec_widgets/widgets/containers/figure/plots/axis_settings.ui +0 -256
- bec_widgets/widgets/containers/figure/plots/image/image.py +0 -772
- bec_widgets/widgets/containers/figure/plots/image/image_item.py +0 -337
- bec_widgets/widgets/containers/figure/plots/motor_map/motor_map.py +0 -525
- bec_widgets/widgets/containers/figure/plots/multi_waveform/multi_waveform.py +0 -340
- bec_widgets/widgets/containers/figure/plots/plot_base.py +0 -505
- bec_widgets/widgets/containers/figure/plots/waveform/waveform.py +0 -1563
- bec_widgets/widgets/plots/image/bec_image_widget.pyproject +0 -1
- bec_widgets/widgets/plots/image/image_widget.py +0 -515
- bec_widgets/widgets/plots/motor_map/bec_motor_map_widget.pyproject +0 -1
- bec_widgets/widgets/plots/motor_map/motor_map_dialog/motor_map_settings.py +0 -56
- bec_widgets/widgets/plots/motor_map/motor_map_dialog/motor_map_settings.ui +0 -108
- bec_widgets/widgets/plots/motor_map/motor_map_widget.py +0 -234
- bec_widgets/widgets/plots/multi_waveform/bec_multi_waveform_widget.pyproject +0 -1
- bec_widgets/widgets/plots/multi_waveform/multi_waveform_controls.ui +0 -99
- bec_widgets/widgets/plots/multi_waveform/multi_waveform_widget.py +0 -536
- bec_widgets/widgets/plots/waveform/bec_waveform_widget.pyproject +0 -1
- bec_widgets/widgets/plots/waveform/waveform_popups/curve_dialog/curve_dialog.py +0 -336
- bec_widgets/widgets/plots/waveform/waveform_popups/curve_dialog/curve_dialog.ui +0 -372
- bec_widgets/widgets/plots/waveform/waveform_popups/dap_summary_dialog/dap_summary_dialog.py +0 -25
- bec_widgets/widgets/plots/waveform/waveform_widget.py +0 -751
- /bec_widgets/{qt_utils → utils}/collapsible_panel_manager.py +0 -0
- /bec_widgets/{applications/alignment → utils/forms_from_types}/__init__.py +0 -0
- /bec_widgets/{qt_utils → utils}/redis_message_waiter.py +0 -0
- /bec_widgets/{applications/alignment/alignment_1d → widgets/containers/auto_update}/__init__.py +0 -0
- /bec_widgets/{qt_utils → widgets/containers/main_window/addons}/__init__.py +0 -0
- /bec_widgets/widgets/{containers/figure/plots → plots/image/toolbar_bundles}/__init__.py +0 -0
- /bec_widgets/widgets/{containers/figure/plots/image → plots/motor_map/settings}/__init__.py +0 -0
- /bec_widgets/widgets/{containers/figure/plots/motor_map → plots/motor_map/toolbar_bundles}/__init__.py +0 -0
- /bec_widgets/widgets/{containers/figure/plots/multi_waveform → plots/multi_waveform/settings}/__init__.py +0 -0
- /bec_widgets/widgets/{containers/figure/plots/waveform → plots/multi_waveform/toolbar_bundles}/__init__.py +0 -0
- /bec_widgets/widgets/plots/{motor_map/motor_map_dialog → scatter_waveform}/__init__.py +0 -0
- /bec_widgets/widgets/plots/{waveform/waveform_popups → scatter_waveform/settings}/__init__.py +0 -0
- /bec_widgets/widgets/plots/{waveform/waveform_popups/curve_dialog → setting_menus}/__init__.py +0 -0
- /bec_widgets/widgets/{plots_next_gen → plots}/setting_menus/axis_settings_horizontal.ui +0 -0
- /bec_widgets/widgets/{plots_next_gen → plots}/setting_menus/axis_settings_vertical.ui +0 -0
- /bec_widgets/widgets/plots/{waveform/waveform_popups/dap_summary_dialog → toolbar_bundles}/__init__.py +0 -0
- /bec_widgets/widgets/{plots_next_gen/setting_menus → plots/waveform/settings}/__init__.py +0 -0
- /bec_widgets/widgets/{plots_next_gen/toolbar_bundles → plots/waveform/settings/curve_settings}/__init__.py +0 -0
- {bec_widgets-1.25.1.dist-info → bec_widgets-2.0.0.dist-info}/WHEEL +0 -0
- {bec_widgets-1.25.1.dist-info → bec_widgets-2.0.0.dist-info}/entry_points.txt +0 -0
- {bec_widgets-1.25.1.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_())
|