mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 10:10:55 +02:00
Compare commits
24 Commits
v2.24.0
...
feature/sc
| Author | SHA1 | Date | |
|---|---|---|---|
| 733bc04e39 | |||
| fa06da1ed6 | |||
| bbcefbb88f | |||
| a185f6f5fe | |||
| c63aa01757 | |||
| 7e469627a2 | |||
|
|
62020f9965 | ||
| 2373c7e996 | |||
|
|
1f3566c105 | ||
| b8ae7b2e96 | |||
| 23674ccf59 | |||
| 1d8069e391 | |||
| 44cc06137c | |||
| 46a91784d2 | |||
| debd347b64 | |||
|
|
a13c3c44c8 | ||
| 25b2737aac | |||
| cf97cc1805 | |||
| 694a6c4960 | |||
| 9caae4cf40 | |||
| 2b06e34ecf | |||
| a9c8995ac0 | |||
|
|
1262c66fd6 | ||
| bde523806f |
64
.github/workflows/child_repos.yml
vendored
Normal file
64
.github/workflows/child_repos.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch for BEC Core'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch for Ophyd Devices'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch for BEC Widgets'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
|
||||
jobs:
|
||||
bec:
|
||||
name: BEC Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Install BEC and dependencies
|
||||
uses: ./.github/actions/bec_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
cd ./bec
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/bec_e2e_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -57,4 +57,24 @@ jobs:
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
|
||||
child-repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -1,6 +1,80 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.27.0 (2025-07-17)
|
||||
|
||||
### Features
|
||||
|
||||
- Add monaco editor
|
||||
([`2373c7e`](https://github.com/bec-project/bec_widgets/commit/2373c7e996566a5b84c5a50e1c3e69de885713db))
|
||||
|
||||
|
||||
## v2.26.0 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **config label**: Reset offset when toggling the label action
|
||||
([`b8ae7b2`](https://github.com/bec-project/bec_widgets/commit/b8ae7b2e96071b6dc59dae7ffa72bbedc6aaea23))
|
||||
|
||||
- **performance_bundle**: Fix performance bundle cleanup
|
||||
([`23674cc`](https://github.com/bec-project/bec_widgets/commit/23674ccf592a2caa0b57ae64ad1499c270b7d469))
|
||||
|
||||
### Features
|
||||
|
||||
- **device combobox**: Add option to insert an empty element
|
||||
([`debd347`](https://github.com/bec-project/bec_widgets/commit/debd347b64a3d2ca07ddcd5ef3a3394d1ffb67e3))
|
||||
|
||||
- **heatmap**: Add interpolation and oversampling UI components
|
||||
([`1d8069e`](https://github.com/bec-project/bec_widgets/commit/1d8069e391412e3096a3c1e7181398dd4e609650))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **image_base**: Cleanup
|
||||
([`46a9178`](https://github.com/bec-project/bec_widgets/commit/46a91784d237137128965ad585e38085e931e5d4))
|
||||
|
||||
### Testing
|
||||
|
||||
- **history**: Add history message helper methods to conftest
|
||||
([`44cc061`](https://github.com/bec-project/bec_widgets/commit/44cc06137ccfbc087bdd3005156ff28effe05f23))
|
||||
|
||||
|
||||
## v2.25.0 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **bec-progressbar**: Add flag for theme update
|
||||
([`694a6c4`](https://github.com/bec-project/bec_widgets/commit/694a6c49608b68e25dc0c76b58855b96f3f0ef0b))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- **bec**: Add child_repos test for bec (unit and e2e tests)
|
||||
([`a9c8995`](https://github.com/bec-project/bec_widgets/commit/a9c8995ac0b39f6bc327887f43f7d4d6e6e89db2))
|
||||
|
||||
- **plugin**: Add plugin repository test to BW ci
|
||||
([`2b06e34`](https://github.com/bec-project/bec_widgets/commit/2b06e34ecff8c0a92a2b235f375e837729736b2a))
|
||||
|
||||
### Features
|
||||
|
||||
- **scan-history-browser**: Add history browser and history metadata viewer
|
||||
([`9caae4c`](https://github.com/bec-project/bec_widgets/commit/9caae4cf40d3876175b827abb735ae227ae0bcea))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Add additional components for history metadata, device view and popup ui
|
||||
([`cf97cc1`](https://github.com/bec-project/bec_widgets/commit/cf97cc1805e16073c7849d1f9375e2ebd2176b70))
|
||||
|
||||
- Cleanup, add compact popup view for scan_history_browser and update tests
|
||||
([`25b2737`](https://github.com/bec-project/bec_widgets/commit/25b2737aacfaa45f255afb6ebf467d5781165a8e))
|
||||
|
||||
|
||||
## v2.24.1 (2025-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Update signal label for device_edit changes
|
||||
([`bde5238`](https://github.com/bec-project/bec_widgets/commit/bde523806fdb6ab224b485f65b615f89dfe20b7b))
|
||||
|
||||
|
||||
## v2.24.0 (2025-07-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -41,6 +41,7 @@ _Widgets = {
|
||||
"Image": "Image",
|
||||
"LogPanel": "LogPanel",
|
||||
"Minesweeper": "Minesweeper",
|
||||
"MonacoWidget": "MonacoWidget",
|
||||
"MotorMap": "MotorMap",
|
||||
"MultiWaveform": "MultiWaveform",
|
||||
"PositionIndicator": "PositionIndicator",
|
||||
@@ -1564,6 +1565,48 @@ class Heatmap(RPCBase):
|
||||
Enable the full colorbar.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def interpolation_method(self) -> "str":
|
||||
"""
|
||||
The interpolation method used for the heatmap.
|
||||
"""
|
||||
|
||||
@interpolation_method.setter
|
||||
@rpc_call
|
||||
def interpolation_method(self) -> "str":
|
||||
"""
|
||||
The interpolation method used for the heatmap.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def oversampling_factor(self) -> "float":
|
||||
"""
|
||||
The oversampling factor for grid resolution.
|
||||
"""
|
||||
|
||||
@oversampling_factor.setter
|
||||
@rpc_call
|
||||
def oversampling_factor(self) -> "float":
|
||||
"""
|
||||
The oversampling factor for grid resolution.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enforce_interpolation(self) -> "bool":
|
||||
"""
|
||||
Whether to enforce interpolation even for grid scans.
|
||||
"""
|
||||
|
||||
@enforce_interpolation.setter
|
||||
@rpc_call
|
||||
def enforce_interpolation(self) -> "bool":
|
||||
"""
|
||||
Whether to enforce interpolation even for grid scans.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def fft(self) -> "bool":
|
||||
@@ -1649,12 +1692,32 @@ class Heatmap(RPCBase):
|
||||
y_entry: "None | str" = None,
|
||||
z_entry: "None | str" = None,
|
||||
color_map: "str | None" = "plasma",
|
||||
label: "str | None" = None,
|
||||
validate_bec: "bool" = True,
|
||||
interpolation: "Literal['linear', 'nearest'] | None" = None,
|
||||
enforce_interpolation: "bool | None" = None,
|
||||
oversampling_factor: "float | None" = None,
|
||||
lock_aspect_ratio: "bool | None" = None,
|
||||
show_config_label: "bool | None" = None,
|
||||
reload: "bool" = False,
|
||||
):
|
||||
"""
|
||||
Plot the heatmap with the given x, y, and z data.
|
||||
|
||||
Args:
|
||||
x_name (str): The name of the x-axis signal.
|
||||
y_name (str): The name of the y-axis signal.
|
||||
z_name (str): The name of the z-axis signal.
|
||||
x_entry (str | None): The entry for the x-axis signal.
|
||||
y_entry (str | None): The entry for the y-axis signal.
|
||||
z_entry (str | None): The entry for the z-axis signal.
|
||||
color_map (str | None): The color map to use for the heatmap.
|
||||
validate_bec (bool): Whether to validate the entries against BEC signals.
|
||||
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
|
||||
enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans.
|
||||
oversampling_factor (float | None): Factor to oversample the grid resolution.
|
||||
lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image.
|
||||
show_config_label (bool | None): Whether to show the configuration label in the heatmap.
|
||||
reload (bool): Whether to reload the heatmap with new data.
|
||||
"""
|
||||
|
||||
|
||||
@@ -2356,6 +2419,98 @@ class LogPanel(RPCBase):
|
||||
class Minesweeper(RPCBase): ...
|
||||
|
||||
|
||||
class MonacoWidget(RPCBase):
|
||||
"""A simple Monaco editor widget"""
|
||||
|
||||
@rpc_call
|
||||
def set_text(self, text: str) -> None:
|
||||
"""
|
||||
Set the text in the Monaco editor.
|
||||
|
||||
Args:
|
||||
text (str): The text to set in the editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
Get the current text from the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_language(self, language: str) -> None:
|
||||
"""
|
||||
Set the programming language for syntax highlighting in the Monaco editor.
|
||||
|
||||
Args:
|
||||
language (str): The programming language to set (e.g., "python", "javascript").
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_language(self) -> str:
|
||||
"""
|
||||
Get the current programming language set in the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""
|
||||
Set the theme for the Monaco editor.
|
||||
|
||||
Args:
|
||||
theme (str): The theme to set (e.g., "vs-dark", "light").
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_theme(self) -> str:
|
||||
"""
|
||||
Get the current theme of the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_readonly(self, read_only: bool) -> None:
|
||||
"""
|
||||
Set the Monaco editor to read-only mode.
|
||||
|
||||
Args:
|
||||
read_only (bool): If True, the editor will be read-only.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_cursor(
|
||||
self,
|
||||
line: int,
|
||||
column: int = 1,
|
||||
move_to_position: Literal[None, "center", "top", "position"] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set the cursor position in the Monaco editor.
|
||||
|
||||
Args:
|
||||
line (int): Line number (1-based).
|
||||
column (int): Column number (1-based), defaults to 1.
|
||||
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def current_cursor(self) -> dict[str, int]:
|
||||
"""
|
||||
Get the current cursor position in the Monaco editor.
|
||||
|
||||
Returns:
|
||||
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_minimap_enabled(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable the minimap in the Monaco editor.
|
||||
|
||||
Args:
|
||||
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
|
||||
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
@@ -142,6 +143,8 @@ class GUIServer:
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
194
bec_widgets/examples/script_interface.py
Normal file
194
bec_widgets/examples/script_interface.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QFrame, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScriptInterface(BECWidget, QWidget):
|
||||
"""
|
||||
A simple script interface widget that allows interaction with Monaco editor and Web Console.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
self.current_script_id = ""
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
|
||||
self.splitter = QSplitter(self)
|
||||
self.splitter.setObjectName("splitter")
|
||||
self.splitter.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.splitter.setOrientation(Qt.Orientation.Vertical)
|
||||
self.splitter.setChildrenCollapsible(True)
|
||||
|
||||
self.monaco_editor = MonacoWidget(self)
|
||||
self.splitter.addWidget(self.monaco_editor)
|
||||
self.web_console = WebConsole(self)
|
||||
self.splitter.addWidget(self.web_console)
|
||||
layout.addWidget(self.toolbar)
|
||||
|
||||
layout.addWidget(self.splitter)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"new_script", MaterialIconAction("add", "New Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"open", MaterialIconAction("folder_open", "Open Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"save", MaterialIconAction("save", "Save Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"run", MaterialIconAction("play_arrow", "Run Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"stop", MaterialIconAction("stop", "Stop Script", parent=self)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("file_io", self.toolbar.components)
|
||||
bundle.add_action("new_script")
|
||||
bundle.add_action("open")
|
||||
bundle.add_action("save")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
bundle = ToolbarBundle("script_execution", self.toolbar.components)
|
||||
bundle.add_action("run")
|
||||
bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.components.get_action("open").action.triggered.connect(self.open_file_dialog)
|
||||
self.toolbar.components.get_action("run").action.triggered.connect(self.run_script)
|
||||
self.toolbar.components.get_action("stop").action.triggered.connect(
|
||||
self.web_console.send_ctrl_c
|
||||
)
|
||||
|
||||
self.set_save_button_enabled(False)
|
||||
|
||||
self.toolbar.show_bundles(["file_io", "script_execution"])
|
||||
self.web_console.set_readonly(True)
|
||||
self._init_file_content = ""
|
||||
|
||||
self._text_changed_proxy = pg.SignalProxy(
|
||||
self.monaco_editor.text_changed, rateLimit=1, slot=self._on_text_changed
|
||||
)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_text_changed(self, text: str):
|
||||
"""
|
||||
Handle text changes in the Monaco editor.
|
||||
"""
|
||||
text = text[0]
|
||||
if text != self._init_file_content:
|
||||
self.set_save_button_enabled(True)
|
||||
else:
|
||||
self.set_save_button_enabled(False)
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
self._current_script_id = value
|
||||
self._update_subscription()
|
||||
|
||||
def _update_subscription(self):
|
||||
if self.current_script_id:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if not current_lines:
|
||||
self.monaco_editor.clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
self.monaco_editor.clear_highlighted_lines()
|
||||
self.monaco_editor.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""
|
||||
Open a file dialog to select a script file.
|
||||
"""
|
||||
start_dir = "./"
|
||||
dialog = QFileDialog(self)
|
||||
dialog.setDirectory(start_dir)
|
||||
dialog.setNameFilter("Python Files (*.py);;All Files (*)")
|
||||
dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
|
||||
|
||||
if dialog.exec():
|
||||
selected_files = dialog.selectedFiles()
|
||||
if not selected_files:
|
||||
return
|
||||
file_path = selected_files[0]
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
content = file.read()
|
||||
self.monaco_editor.set_text(content)
|
||||
self._init_file_content = content
|
||||
|
||||
logger.info(f"Selected files: {selected_files}")
|
||||
|
||||
def set_save_button_enabled(self, enabled: bool):
|
||||
"""
|
||||
Set the save button enabled state.
|
||||
"""
|
||||
action = self.toolbar.components.get_action("save")
|
||||
if action:
|
||||
action.action.setEnabled(enabled)
|
||||
|
||||
def run_script(self):
|
||||
print("Running script...")
|
||||
script_id = str(uuid.uuid4())
|
||||
self.current_script_id = script_id
|
||||
script_text = self.monaco_editor.get_text()
|
||||
|
||||
script_text = f'bec._run_script("{script_id}", """{script_text}""")'
|
||||
script_text = script_text.replace("\n", "\\n").replace("'", "\\'").strip()
|
||||
if not script_text.endswith("\n"):
|
||||
script_text += "\\n"
|
||||
self.web_console.write(script_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
script_interface = ScriptInterface()
|
||||
script_interface.resize(800, 600)
|
||||
script_interface.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
@@ -42,11 +43,15 @@ class PerformanceConnection(BundleConnection):
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_fps_monitor(self, enabled: bool):
|
||||
setattr(self.target_widget, "enable_fps_monitor", enabled)
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
|
||||
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
|
||||
self.set_fps_monitor
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
@@ -54,5 +59,6 @@ class PerformanceConnection(BundleConnection):
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
|
||||
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
|
||||
self.set_fps_monitor
|
||||
)
|
||||
self._connected = False
|
||||
|
||||
@@ -69,7 +69,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
Args:
|
||||
signal (str): signal name.
|
||||
"""
|
||||
if self.validate_signal(signal) is True:
|
||||
if self.validate_signal(signal):
|
||||
WidgetIO.set_value(widget=self, value=signal)
|
||||
self.config.default = signal
|
||||
else:
|
||||
|
||||
@@ -5,6 +5,7 @@ from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
BECDeviceFilter,
|
||||
DeviceInputBase,
|
||||
@@ -61,6 +62,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self._callback_id = None
|
||||
self._is_valid_input = False
|
||||
self._accent_colors = get_accent_colors()
|
||||
self._set_first_element_as_empty = False
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
@@ -93,6 +95,31 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
self.currentTextChanged.connect(self.check_validity)
|
||||
self.check_validity(self.currentText())
|
||||
|
||||
@SafeProperty(bool)
|
||||
def set_first_element_as_empty(self) -> bool:
|
||||
"""
|
||||
Whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
"""
|
||||
return self._set_first_element_as_empty
|
||||
|
||||
@set_first_element_as_empty.setter
|
||||
def set_first_element_as_empty(self, value: bool) -> None:
|
||||
"""
|
||||
Set whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
|
||||
Args:
|
||||
value (bool): True if the first element should be empty, False otherwise.
|
||||
"""
|
||||
self._set_first_element_as_empty = value
|
||||
if self._set_first_element_as_empty:
|
||||
self.insertItem(0, "")
|
||||
self.setCurrentIndex(0)
|
||||
else:
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""
|
||||
Callback for device update events. Triggers the device_update signal.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
@@ -54,6 +56,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.setMinimumSize(QSize(100, 0))
|
||||
self._set_first_element_as_empty = True
|
||||
# We do not consider the config that is passed here, this produced problems
|
||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
||||
# Implementing this logic and config recoverage is postponed.
|
||||
@@ -90,6 +93,31 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.insertItem(0, "Hinted Signals")
|
||||
self.model().item(0).setEnabled(False)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def set_first_element_as_empty(self) -> bool:
|
||||
"""
|
||||
Whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
"""
|
||||
return self._set_first_element_as_empty
|
||||
|
||||
@set_first_element_as_empty.setter
|
||||
def set_first_element_as_empty(self, value: bool) -> None:
|
||||
"""
|
||||
Set whether the first element in the combobox should be empty.
|
||||
This is useful to allow the user to select a device from the list.
|
||||
|
||||
Args:
|
||||
value (bool): True if the first element should be empty, False otherwise.
|
||||
"""
|
||||
self._set_first_element_as_empty = value
|
||||
if self._set_first_element_as_empty:
|
||||
self.insertItem(0, "")
|
||||
self.setCurrentIndex(0)
|
||||
else:
|
||||
if self.count() > 0 and self.itemText(0) == "":
|
||||
self.removeItem(0)
|
||||
|
||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||
"""
|
||||
Set the combobox to the object name of the signal.
|
||||
@@ -142,6 +170,10 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
return
|
||||
self.device_signal_changed.emit(text)
|
||||
|
||||
@property
|
||||
def selected_signal_comp_name(self) -> str:
|
||||
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
0
bec_widgets/widgets/editors/monaco/__init__.py
Normal file
0
bec_widgets/widgets/editors/monaco/__init__.py
Normal file
191
bec_widgets/widgets/editors/monaco/monaco_widget.py
Normal file
191
bec_widgets/widgets/editors/monaco/monaco_widget.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from typing import Literal
|
||||
|
||||
import qtmonaco
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_name
|
||||
|
||||
|
||||
class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
A simple Monaco editor widget
|
||||
"""
|
||||
|
||||
text_changed = Signal(str)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "code"
|
||||
USER_ACCESS = [
|
||||
"set_text",
|
||||
"get_text",
|
||||
"set_language",
|
||||
"get_language",
|
||||
"set_theme",
|
||||
"get_theme",
|
||||
"set_readonly",
|
||||
"set_cursor",
|
||||
"current_cursor",
|
||||
"set_minimap_enabled",
|
||||
]
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.editor = qtmonaco.Monaco(self)
|
||||
layout.addWidget(self.editor)
|
||||
self.setLayout(layout)
|
||||
self.editor.text_changed.connect(self.text_changed.emit)
|
||||
self.editor.initialized.connect(self.apply_theme)
|
||||
|
||||
def apply_theme(self, theme: str | None = None) -> None:
|
||||
"""
|
||||
Apply the current theme to the Monaco editor.
|
||||
|
||||
Args:
|
||||
theme (str, optional): The theme to apply. If None, the current theme will be used.
|
||||
"""
|
||||
if theme is None:
|
||||
theme = get_theme_name()
|
||||
editor_theme = "vs" if theme == "light" else "vs-dark"
|
||||
self.set_theme(editor_theme)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
"""
|
||||
Set the text in the Monaco editor.
|
||||
|
||||
Args:
|
||||
text (str): The text to set in the editor.
|
||||
"""
|
||||
self.editor.set_text(text)
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
Get the current text from the Monaco editor.
|
||||
"""
|
||||
return self.editor.get_text()
|
||||
|
||||
def set_cursor(
|
||||
self,
|
||||
line: int,
|
||||
column: int = 1,
|
||||
move_to_position: Literal[None, "center", "top", "position"] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set the cursor position in the Monaco editor.
|
||||
|
||||
Args:
|
||||
line (int): Line number (1-based).
|
||||
column (int): Column number (1-based), defaults to 1.
|
||||
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
|
||||
"""
|
||||
self.editor.set_cursor(line, column, move_to_position)
|
||||
|
||||
def current_cursor(self) -> dict[str, int]:
|
||||
"""
|
||||
Get the current cursor position in the Monaco editor.
|
||||
|
||||
Returns:
|
||||
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
|
||||
"""
|
||||
return self.editor.current_cursor
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
"""
|
||||
Set the programming language for syntax highlighting in the Monaco editor.
|
||||
|
||||
Args:
|
||||
language (str): The programming language to set (e.g., "python", "javascript").
|
||||
"""
|
||||
self.editor.set_language(language)
|
||||
|
||||
def get_language(self) -> str:
|
||||
"""
|
||||
Get the current programming language set in the Monaco editor.
|
||||
"""
|
||||
return self.editor.get_language()
|
||||
|
||||
def set_readonly(self, read_only: bool) -> None:
|
||||
"""
|
||||
Set the Monaco editor to read-only mode.
|
||||
|
||||
Args:
|
||||
read_only (bool): If True, the editor will be read-only.
|
||||
"""
|
||||
self.editor.set_readonly(read_only)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""
|
||||
Set the theme for the Monaco editor.
|
||||
|
||||
Args:
|
||||
theme (str): The theme to set (e.g., "vs-dark", "light").
|
||||
"""
|
||||
self.editor.set_theme(theme)
|
||||
|
||||
def get_theme(self) -> str:
|
||||
"""
|
||||
Get the current theme of the Monaco editor.
|
||||
"""
|
||||
return self.editor.get_theme()
|
||||
|
||||
def set_minimap_enabled(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable the minimap in the Monaco editor.
|
||||
|
||||
Args:
|
||||
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
self.editor.set_minimap_enabled(enabled)
|
||||
|
||||
def set_highlighted_lines(self, start_line: int, end_line: int) -> None:
|
||||
"""
|
||||
Highlight a range of lines in the Monaco editor.
|
||||
|
||||
Args:
|
||||
start_line (int): The starting line number (1-based).
|
||||
end_line (int): The ending line number (1-based).
|
||||
"""
|
||||
self.editor.set_highlighted_lines(start_line, end_line)
|
||||
|
||||
def clear_highlighted_lines(self) -> None:
|
||||
"""
|
||||
Clear any highlighted lines in the Monaco editor.
|
||||
"""
|
||||
self.editor.clear_highlighted_lines()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
qapp = QApplication([])
|
||||
widget = MonacoWidget()
|
||||
# set the default size
|
||||
widget.resize(800, 600)
|
||||
widget.set_language("python")
|
||||
widget.set_theme("vs-dark")
|
||||
widget.editor.set_minimap_enabled(False)
|
||||
widget.set_text(
|
||||
"""
|
||||
import numpy as np
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.devicemanager import DeviceContainer
|
||||
from bec_lib.scans import Scans
|
||||
dev: DeviceContainer
|
||||
scans: Scans
|
||||
|
||||
#######################################
|
||||
########## User Script #####################
|
||||
#######################################
|
||||
|
||||
# This is a comment
|
||||
def hello_world():
|
||||
print("Hello, world!")
|
||||
"""
|
||||
)
|
||||
widget.set_highlighted_lines(1, 3)
|
||||
widget.show()
|
||||
qapp.exec_()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['monaco_widget.py']}
|
||||
54
bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py
Normal file
54
bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='MonacoWidget' name='monaco_widget'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class MonacoWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = MonacoWidget(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Developer"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(MonacoWidget.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "monaco_widget"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "MonacoWidget"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/editors/monaco/register_monaco_widget.py
Normal file
15
bec_widgets/widgets/editors/monaco/register_monaco_widget.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget_plugin import MonacoWidgetPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(MonacoWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -6,11 +6,12 @@ import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import QUrl, qInstallMessageHandler
|
||||
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
|
||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -165,11 +166,16 @@ class WebConsole(BECWidget, QWidget):
|
||||
A simple widget to display a website
|
||||
"""
|
||||
|
||||
_js_callback = Signal(bool)
|
||||
initialized = Signal()
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._startup_cmd = "bec --nogui"
|
||||
self._is_initialized = False
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
layout = QVBoxLayout()
|
||||
@@ -181,6 +187,48 @@ class WebConsole(BECWidget, QWidget):
|
||||
layout.addWidget(self.browser)
|
||||
self.setLayout(layout)
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
self._startup_timer = QTimer()
|
||||
self._startup_timer.setInterval(1000)
|
||||
self._startup_timer.timeout.connect(self._check_page_ready)
|
||||
self._startup_timer.start()
|
||||
self._js_callback.connect(self._on_js_callback)
|
||||
|
||||
def _check_page_ready(self):
|
||||
"""
|
||||
Check if the page is ready and stop the timer if it is.
|
||||
"""
|
||||
if self.page.isLoading():
|
||||
return
|
||||
|
||||
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
|
||||
|
||||
def _on_js_callback(self, ready: bool):
|
||||
"""
|
||||
Callback for when the JavaScript is ready.
|
||||
"""
|
||||
if not ready:
|
||||
return
|
||||
self._is_initialized = True
|
||||
self._startup_timer.stop()
|
||||
if self._startup_cmd:
|
||||
self.write(self._startup_cmd)
|
||||
self.initialized.emit()
|
||||
|
||||
@SafeProperty(str)
|
||||
def startup_cmd(self):
|
||||
"""
|
||||
Get the startup command for the web console.
|
||||
"""
|
||||
return self._startup_cmd
|
||||
|
||||
@startup_cmd.setter
|
||||
def startup_cmd(self, cmd: str):
|
||||
"""
|
||||
Set the startup command for the web console.
|
||||
"""
|
||||
if not isinstance(cmd, str):
|
||||
raise ValueError("Startup command must be a string.")
|
||||
self._startup_cmd = cmd
|
||||
|
||||
def write(self, data: str, send_return: bool = True):
|
||||
"""
|
||||
@@ -213,10 +261,19 @@ class WebConsole(BECWidget, QWidget):
|
||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
||||
)
|
||||
|
||||
def set_readonly(self, readonly: bool):
|
||||
"""
|
||||
Set the web console to read-only mode.
|
||||
"""
|
||||
if not isinstance(readonly, bool):
|
||||
raise ValueError("Readonly must be a boolean.")
|
||||
self.setEnabled(not readonly)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the registry by removing any instances that are no longer valid.
|
||||
"""
|
||||
self._startup_timer.stop()
|
||||
_web_console_registry.unregister(self)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
@@ -11,7 +10,11 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
from scipy.interpolate import (
|
||||
CloughTocher2DInterpolator,
|
||||
LinearNDInterpolator,
|
||||
NearestNDInterpolator,
|
||||
)
|
||||
from scipy.spatial import cKDTree
|
||||
from toolz import partition
|
||||
|
||||
@@ -44,6 +47,19 @@ class HeatmapConfig(ConnectionConfig):
|
||||
color_bar: Literal["full", "simple"] | None = Field(
|
||||
None, description="The type of the color bar."
|
||||
)
|
||||
interpolation: Literal["linear", "nearest", "clough"] = Field(
|
||||
"linear", description="The interpolation method for the heatmap."
|
||||
)
|
||||
oversampling_factor: float = Field(
|
||||
1.0,
|
||||
description="Factor to oversample the grid resolution (1.0 = no oversampling, 2.0 = 2x resolution).",
|
||||
)
|
||||
show_config_label: bool = Field(
|
||||
True, description="Whether to show the configuration label in the heatmap."
|
||||
)
|
||||
enforce_interpolation: bool = Field(
|
||||
False, description="Whether to use the interpolation mode even for grid scans."
|
||||
)
|
||||
lock_aspect_ratio: bool = Field(
|
||||
False, description="Whether to lock the aspect ratio of the image."
|
||||
)
|
||||
@@ -119,6 +135,12 @@ class Heatmap(ImageBase):
|
||||
"enable_simple_colorbar.setter",
|
||||
"enable_full_colorbar",
|
||||
"enable_full_colorbar.setter",
|
||||
"interpolation_method",
|
||||
"interpolation_method.setter",
|
||||
"oversampling_factor",
|
||||
"oversampling_factor.setter",
|
||||
"enforce_interpolation",
|
||||
"enforce_interpolation.setter",
|
||||
"fft",
|
||||
"fft.setter",
|
||||
"log",
|
||||
@@ -141,8 +163,19 @@ class Heatmap(ImageBase):
|
||||
|
||||
def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs):
|
||||
if config is None:
|
||||
config = HeatmapConfig(widget_class=self.__class__.__name__)
|
||||
super().__init__(parent=parent, config=config, **kwargs)
|
||||
config = HeatmapConfig(
|
||||
widget_class=self.__class__.__name__,
|
||||
parent_id=None,
|
||||
color_map="plasma",
|
||||
color_bar=None,
|
||||
interpolation="linear",
|
||||
oversampling_factor=1.0,
|
||||
lock_aspect_ratio=False,
|
||||
x_device=None,
|
||||
y_device=None,
|
||||
z_device=None,
|
||||
)
|
||||
super().__init__(parent=parent, config=config, theme_update=True, **kwargs)
|
||||
self._image_config = config
|
||||
self.scan_id = None
|
||||
self.old_scan_id = None
|
||||
@@ -150,9 +183,16 @@ class Heatmap(ImageBase):
|
||||
self.status_message = None
|
||||
self._grid_index = None
|
||||
self.heatmap_dialog = None
|
||||
bg_color = pg.mkColor((240, 240, 240, 150))
|
||||
self.config_label = pg.LegendItem(
|
||||
labelTextColor=(0, 0, 0), offset=(-30, 1), brush=pg.mkBrush(bg_color), horSpacing=0
|
||||
)
|
||||
self.config_label.setParentItem(self.plot_item.vb)
|
||||
self.config_label.setVisible(False)
|
||||
self.reload = False
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
self.heatmap_property_changed.connect(lambda: self.sync_signal_update.emit())
|
||||
|
||||
self.proxy_update_sync = pg.SignalProxy(
|
||||
self.sync_signal_update, rateLimit=5, slot=self.update_plot
|
||||
@@ -168,6 +208,7 @@ class Heatmap(ImageBase):
|
||||
"image_colorbar",
|
||||
"image_processing",
|
||||
"axis_popup",
|
||||
"interpolation_info",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -180,6 +221,23 @@ class Heatmap(ImageBase):
|
||||
# Widget Specific GUI interactions
|
||||
################################################################################
|
||||
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the current theme to the heatmap widget.
|
||||
"""
|
||||
super().apply_theme(theme)
|
||||
if theme == "dark":
|
||||
brush = pg.mkBrush(pg.mkColor(50, 50, 50, 150))
|
||||
color = pg.mkColor(255, 255, 255)
|
||||
else:
|
||||
brush = pg.mkBrush(pg.mkColor(240, 240, 240, 150))
|
||||
color = pg.mkColor(0, 0, 0)
|
||||
if hasattr(self, "config_label"):
|
||||
self.config_label.setBrush(brush)
|
||||
self.config_label.setLabelTextColor(color)
|
||||
self.redraw_config_label()
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def plot(
|
||||
self,
|
||||
@@ -190,12 +248,32 @@ class Heatmap(ImageBase):
|
||||
y_entry: None | str = None,
|
||||
z_entry: None | str = None,
|
||||
color_map: str | None = "plasma",
|
||||
label: str | None = None,
|
||||
validate_bec: bool = True,
|
||||
interpolation: Literal["linear", "nearest"] | None = None,
|
||||
enforce_interpolation: bool | None = None,
|
||||
oversampling_factor: float | None = None,
|
||||
lock_aspect_ratio: bool | None = None,
|
||||
show_config_label: bool | None = None,
|
||||
reload: bool = False,
|
||||
):
|
||||
"""
|
||||
Plot the heatmap with the given x, y, and z data.
|
||||
|
||||
Args:
|
||||
x_name (str): The name of the x-axis signal.
|
||||
y_name (str): The name of the y-axis signal.
|
||||
z_name (str): The name of the z-axis signal.
|
||||
x_entry (str | None): The entry for the x-axis signal.
|
||||
y_entry (str | None): The entry for the y-axis signal.
|
||||
z_entry (str | None): The entry for the z-axis signal.
|
||||
color_map (str | None): The color map to use for the heatmap.
|
||||
validate_bec (bool): Whether to validate the entries against BEC signals.
|
||||
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
|
||||
enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans.
|
||||
oversampling_factor (float | None): Factor to oversample the grid resolution.
|
||||
lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image.
|
||||
show_config_label (bool | None): Whether to show the configuration label in the heatmap.
|
||||
reload (bool): Whether to reload the heatmap with new data.
|
||||
"""
|
||||
if validate_bec:
|
||||
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
|
||||
@@ -207,12 +285,33 @@ class Heatmap(ImageBase):
|
||||
if x_name is None or y_name is None or z_name is None:
|
||||
raise ValueError("x, y, and z names must be provided.")
|
||||
|
||||
if interpolation is None:
|
||||
interpolation = self._image_config.interpolation
|
||||
|
||||
if oversampling_factor is None:
|
||||
oversampling_factor = self._image_config.oversampling_factor
|
||||
|
||||
if enforce_interpolation is None:
|
||||
enforce_interpolation = self._image_config.enforce_interpolation
|
||||
|
||||
if lock_aspect_ratio is None:
|
||||
lock_aspect_ratio = self._image_config.lock_aspect_ratio
|
||||
|
||||
if show_config_label is None:
|
||||
show_config_label = self._image_config.show_config_label
|
||||
|
||||
self._image_config = HeatmapConfig(
|
||||
parent_id=self.gui_id,
|
||||
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
|
||||
y_device=HeatmapDeviceSignal(name=y_name, entry=y_entry),
|
||||
z_device=HeatmapDeviceSignal(name=z_name, entry=z_entry),
|
||||
color_map=color_map,
|
||||
color_bar=None,
|
||||
interpolation=interpolation,
|
||||
oversampling_factor=oversampling_factor,
|
||||
enforce_interpolation=enforce_interpolation,
|
||||
lock_aspect_ratio=lock_aspect_ratio,
|
||||
show_config_label=show_config_label,
|
||||
)
|
||||
self.color_map = color_map
|
||||
self.reload = reload
|
||||
@@ -230,7 +329,6 @@ class Heatmap(ImageBase):
|
||||
self.scan_item = self.client.history[-1]
|
||||
self.scan_id = self.client.history._scan_ids[-1]
|
||||
self.old_scan_id = None
|
||||
self.update_plot()
|
||||
|
||||
def update_labels(self):
|
||||
"""
|
||||
@@ -279,6 +377,19 @@ class Heatmap(ImageBase):
|
||||
if name not in ["image_processing_fft", "image_processing_log"]:
|
||||
action().action.setVisible(False)
|
||||
|
||||
self.toolbar.add_action(
|
||||
"interpolation_info",
|
||||
MaterialIconAction(
|
||||
icon_name="info", tooltip="Show Interpolation Info", checkable=True, parent=self
|
||||
),
|
||||
)
|
||||
self.toolbar.components.get_action("interpolation_info").action.triggered.connect(
|
||||
self.toggle_interpolation_info
|
||||
)
|
||||
self.toolbar.components.get_action("interpolation_info").action.setChecked(
|
||||
self._image_config.show_config_label
|
||||
)
|
||||
|
||||
def show_heatmap_settings(self):
|
||||
"""
|
||||
Show the heatmap settings dialog.
|
||||
@@ -289,7 +400,7 @@ class Heatmap(ImageBase):
|
||||
self.heatmap_dialog = SettingsDialog(
|
||||
self, settings_widget=heatmap_settings, window_title="Heatmap Settings", modal=False
|
||||
)
|
||||
self.heatmap_dialog.resize(620, 200)
|
||||
self.heatmap_dialog.resize(700, 350)
|
||||
# When the dialog is closed, update the toolbar icon and clear the reference
|
||||
self.heatmap_dialog.finished.connect(self._heatmap_dialog_closed)
|
||||
self.heatmap_dialog.show()
|
||||
@@ -300,6 +411,16 @@ class Heatmap(ImageBase):
|
||||
self.heatmap_dialog.activateWindow()
|
||||
heatmap_settings_action.setChecked(True) # keep it toggled
|
||||
|
||||
def toggle_interpolation_info(self):
|
||||
"""
|
||||
Toggle the visibility of the interpolation info label.
|
||||
"""
|
||||
self._image_config.show_config_label = not self._image_config.show_config_label
|
||||
self.toolbar.components.get_action("interpolation_info").action.setChecked(
|
||||
self._image_config.show_config_label
|
||||
)
|
||||
self.redraw_config_label()
|
||||
|
||||
def _heatmap_dialog_closed(self):
|
||||
"""
|
||||
Slot for when the heatmap settings dialog is closed.
|
||||
@@ -397,6 +518,7 @@ class Heatmap(ImageBase):
|
||||
scan_id = metadata["scan_id"]
|
||||
scan_name = metadata["scan_name"]
|
||||
scan_type = metadata["scan_type"]
|
||||
scan_number = metadata["scan_number"]
|
||||
request_inputs = metadata["request_inputs"]
|
||||
if "arg_bundle" in request_inputs and isinstance(request_inputs["arg_bundle"], str):
|
||||
# Convert the arg_bundle from a JSON string to a dictionary
|
||||
@@ -408,6 +530,7 @@ class Heatmap(ImageBase):
|
||||
status=status,
|
||||
scan_id=scan_id,
|
||||
scan_name=scan_name,
|
||||
scan_number=scan_number,
|
||||
scan_type=scan_type,
|
||||
request_inputs=request_inputs,
|
||||
info={"positions": positions},
|
||||
@@ -420,6 +543,9 @@ class Heatmap(ImageBase):
|
||||
return
|
||||
self.status_message = scan_msg
|
||||
|
||||
if self._image_config.show_config_label:
|
||||
self.redraw_config_label()
|
||||
|
||||
img, transform = self.get_image_data(x_data=x_data, y_data=y_data, z_data=z_data)
|
||||
if img is None:
|
||||
logger.warning("Image data is None; skipping update.")
|
||||
@@ -434,6 +560,27 @@ class Heatmap(ImageBase):
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.update_markers_on_image_change()
|
||||
|
||||
def redraw_config_label(self):
|
||||
scan_msg = self.status_message
|
||||
if scan_msg is None:
|
||||
return
|
||||
if not self._image_config.show_config_label:
|
||||
self.config_label.setVisible(False)
|
||||
return
|
||||
|
||||
self.config_label.setOffset((-30, 1))
|
||||
self.config_label.setVisible(True)
|
||||
self.config_label.clear()
|
||||
self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number}")
|
||||
self.config_label.addItem(self.plot_item, f"Scan Name: {scan_msg.scan_name}")
|
||||
if scan_msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
|
||||
self.config_label.addItem(
|
||||
self.plot_item, f"Interpolation: {self._image_config.interpolation}"
|
||||
)
|
||||
self.config_label.addItem(
|
||||
self.plot_item, f"Oversampling: {self._image_config.oversampling_factor}x"
|
||||
)
|
||||
|
||||
def get_image_data(
|
||||
self,
|
||||
x_data: list[float] | None = None,
|
||||
@@ -458,7 +605,7 @@ class Heatmap(ImageBase):
|
||||
logger.warning("x, y, or z data is None; skipping update.")
|
||||
return None, None
|
||||
|
||||
if msg.scan_name == "grid_scan":
|
||||
if msg.scan_name == "grid_scan" and not self._image_config.enforce_interpolation:
|
||||
# We only support the grid scan mode if both scanning motors
|
||||
# are configured in the heatmap config.
|
||||
device_x = self._image_config.x_device.entry
|
||||
@@ -571,7 +718,16 @@ class Heatmap(ImageBase):
|
||||
grid_x, grid_y, transform = self.get_image_grid(xy_data)
|
||||
|
||||
# Interpolate the z data onto the grid
|
||||
interp = LinearNDInterpolator(xy_data, z_data)
|
||||
if self._image_config.interpolation == "linear":
|
||||
interp = LinearNDInterpolator(xy_data, z_data)
|
||||
elif self._image_config.interpolation == "nearest":
|
||||
interp = NearestNDInterpolator(xy_data, z_data)
|
||||
elif self._image_config.interpolation == "clough":
|
||||
interp = CloughTocher2DInterpolator(xy_data, z_data)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Interpolation method must be either 'linear', 'nearest', or 'clough'."
|
||||
)
|
||||
grid_z = interp(grid_x, grid_y)
|
||||
|
||||
return grid_z, transform
|
||||
@@ -587,20 +743,24 @@ class Heatmap(ImageBase):
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
|
||||
"""
|
||||
base_width, base_height = self.estimate_image_resolution(positions)
|
||||
|
||||
width, height = self.estimate_image_resolution(positions)
|
||||
# Apply oversampling factor
|
||||
factor = self._image_config.oversampling_factor
|
||||
|
||||
# Create a grid of points for interpolation
|
||||
# Apply oversampling
|
||||
width = int(base_width * factor)
|
||||
height = int(base_height * factor)
|
||||
|
||||
# Create grid
|
||||
grid_x, grid_y = np.mgrid[
|
||||
min(positions[:, 0]) : max(positions[:, 0]) : width * 1j,
|
||||
min(positions[:, 1]) : max(positions[:, 1]) : height * 1j,
|
||||
]
|
||||
|
||||
# Calculate the QTransform to put (0,0) at the axis origin
|
||||
x_min = min(positions[:, 0])
|
||||
y_min = min(positions[:, 1])
|
||||
x_max = max(positions[:, 0])
|
||||
y_max = max(positions[:, 1])
|
||||
# Calculate transform
|
||||
x_min, x_max = min(positions[:, 0]), max(positions[:, 0])
|
||||
y_min, y_max = min(positions[:, 1]), max(positions[:, 1])
|
||||
x_range = x_max - x_min
|
||||
y_range = y_max - y_min
|
||||
x_scale = x_range / width
|
||||
@@ -670,7 +830,7 @@ class Heatmap(ImageBase):
|
||||
# Optionally fetch the latest from history if nothing is set
|
||||
# self.update_with_scan_history(-1)
|
||||
if self.scan_item is None:
|
||||
logger.info("No scan executed so far; skipping device curves categorisation.")
|
||||
logger.info("No scan executed so far; skipping update.")
|
||||
return "none", "none"
|
||||
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
@@ -688,6 +848,62 @@ class Heatmap(ImageBase):
|
||||
self.crosshair.reset()
|
||||
super().reset()
|
||||
|
||||
@SafeProperty(str)
|
||||
def interpolation_method(self) -> str:
|
||||
"""
|
||||
The interpolation method used for the heatmap.
|
||||
"""
|
||||
return self._image_config.interpolation
|
||||
|
||||
@interpolation_method.setter
|
||||
def interpolation_method(self, value: str):
|
||||
"""
|
||||
Set the interpolation method for the heatmap.
|
||||
Args:
|
||||
value(str): The interpolation method, either 'linear' or 'nearest'.
|
||||
"""
|
||||
if value not in ["linear", "nearest"]:
|
||||
raise ValueError("Interpolation method must be either 'linear' or 'nearest'.")
|
||||
self._image_config.interpolation = value
|
||||
self.heatmap_property_changed.emit()
|
||||
|
||||
@SafeProperty(float)
|
||||
def oversampling_factor(self) -> float:
|
||||
"""
|
||||
The oversampling factor for grid resolution.
|
||||
"""
|
||||
return self._image_config.oversampling_factor
|
||||
|
||||
@oversampling_factor.setter
|
||||
def oversampling_factor(self, value: float):
|
||||
"""
|
||||
Set the oversampling factor for grid resolution.
|
||||
Args:
|
||||
value(float): The oversampling factor (1.0 = no oversampling, 2.0 = 2x resolution).
|
||||
"""
|
||||
if value <= 0:
|
||||
raise ValueError("Oversampling factor must be greater than 0.")
|
||||
self._image_config.oversampling_factor = value
|
||||
self.heatmap_property_changed.emit()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enforce_interpolation(self) -> bool:
|
||||
"""
|
||||
Whether to enforce interpolation even for grid scans.
|
||||
"""
|
||||
return self._image_config.enforce_interpolation
|
||||
|
||||
@enforce_interpolation.setter
|
||||
def enforce_interpolation(self, value: bool):
|
||||
"""
|
||||
Set whether to enforce interpolation even for grid scans.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to enforce interpolation.
|
||||
"""
|
||||
self._image_config.enforce_interpolation = value
|
||||
self.heatmap_property_changed.emit()
|
||||
|
||||
################################################################################
|
||||
# Post Processing
|
||||
################################################################################
|
||||
@@ -768,6 +984,6 @@ if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
heatmap = Heatmap()
|
||||
heatmap.plot(x_name="samx", y_name="samy", z_name="bpm4i")
|
||||
heatmap.plot(x_name="samx", y_name="samy", z_name="bpm4i", oversampling_factor=5.0)
|
||||
heatmap.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -27,7 +27,7 @@ class HeatmapPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
return "Plot Widgets"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(Heatmap.ICON_NAME)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||
|
||||
@@ -6,6 +9,11 @@ from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||
SignalComboBox,
|
||||
)
|
||||
|
||||
|
||||
class HeatmapSettings(SettingWidget):
|
||||
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
|
||||
@@ -46,6 +54,8 @@ class HeatmapSettings(SettingWidget):
|
||||
if popup is False:
|
||||
self.ui.button_apply.clicked.connect(self.accept_changes)
|
||||
|
||||
self.ui.x_name.setFocus()
|
||||
|
||||
@SafeSlot()
|
||||
def fetch_all_properties(self):
|
||||
"""
|
||||
@@ -85,31 +95,66 @@ class HeatmapSettings(SettingWidget):
|
||||
if hasattr(self.ui, "x_name"):
|
||||
self.ui.x_name.set_device(x_name)
|
||||
if hasattr(self.ui, "x_entry") and x_entry is not None:
|
||||
self.ui.x_entry.setText(x_entry)
|
||||
self.ui.x_entry.set_to_obj_name(x_entry)
|
||||
|
||||
if hasattr(self.ui, "y_name"):
|
||||
self.ui.y_name.set_device(y_name)
|
||||
if hasattr(self.ui, "y_entry") and y_entry is not None:
|
||||
self.ui.y_entry.setText(y_entry)
|
||||
self.ui.y_entry.set_to_obj_name(y_entry)
|
||||
|
||||
if hasattr(self.ui, "z_name"):
|
||||
self.ui.z_name.set_device(z_name)
|
||||
if hasattr(self.ui, "z_entry") and z_entry is not None:
|
||||
self.ui.z_entry.setText(z_entry)
|
||||
self.ui.z_entry.set_to_obj_name(z_entry)
|
||||
|
||||
if hasattr(self.ui, "interpolation"):
|
||||
self.ui.interpolation.setCurrentText(
|
||||
getattr(self.target_widget._image_config, "interpolation", "linear")
|
||||
)
|
||||
if hasattr(self.ui, "oversampling_factor"):
|
||||
self.ui.oversampling_factor.setValue(
|
||||
getattr(self.target_widget._image_config, "oversampling_factor", 1.0)
|
||||
)
|
||||
if hasattr(self.ui, "enforce_interpolation"):
|
||||
self.ui.enforce_interpolation.setChecked(
|
||||
getattr(self.target_widget._image_config, "enforce_interpolation", False)
|
||||
)
|
||||
|
||||
def _get_signal_name(self, signal: SignalComboBox) -> str:
|
||||
"""
|
||||
Get the signal name from the signal combobox.
|
||||
Args:
|
||||
signal (SignalComboBox): The signal combobox to get the name from.
|
||||
Returns:
|
||||
str: The signal name.
|
||||
"""
|
||||
device_entry = signal.currentText()
|
||||
index = signal.findText(device_entry)
|
||||
if index == -1:
|
||||
return device_entry
|
||||
|
||||
device_entry_info = signal.itemData(index)
|
||||
if device_entry_info:
|
||||
device_entry = device_entry_info.get("obj_name", device_entry)
|
||||
|
||||
return device_entry if device_entry else ""
|
||||
|
||||
@SafeSlot()
|
||||
def accept_changes(self):
|
||||
"""
|
||||
Apply all properties from the settings widget to the target widget.
|
||||
"""
|
||||
x_name = self.ui.x_name.text()
|
||||
x_entry = self.ui.x_entry.text()
|
||||
y_name = self.ui.y_name.text()
|
||||
y_entry = self.ui.y_entry.text()
|
||||
z_name = self.ui.z_name.text()
|
||||
z_entry = self.ui.z_entry.text()
|
||||
x_name = self.ui.x_name.currentText()
|
||||
x_entry = self._get_signal_name(self.ui.x_entry)
|
||||
y_name = self.ui.y_name.currentText()
|
||||
y_entry = self._get_signal_name(self.ui.y_entry)
|
||||
z_name = self.ui.z_name.currentText()
|
||||
z_entry = self._get_signal_name(self.ui.z_entry)
|
||||
validate_bec = self.ui.validate_bec.checked
|
||||
color_map = self.ui.color_map.colormap
|
||||
interpolation = self.ui.interpolation.currentText()
|
||||
oversampling_factor = self.ui.oversampling_factor.value()
|
||||
enforce_interpolation = self.ui.enforce_interpolation.isChecked()
|
||||
|
||||
self.target_widget.plot(
|
||||
x_name=x_name,
|
||||
@@ -120,6 +165,9 @@ class HeatmapSettings(SettingWidget):
|
||||
z_entry=z_entry,
|
||||
color_map=color_map,
|
||||
validate_bec=validate_bec,
|
||||
interpolation=interpolation,
|
||||
oversampling_factor=oversampling_factor,
|
||||
enforce_interpolation=enforce_interpolation,
|
||||
reload=True,
|
||||
)
|
||||
|
||||
@@ -136,3 +184,5 @@ class HeatmapSettings(SettingWidget):
|
||||
self.ui.z_name.deleteLater()
|
||||
self.ui.z_entry.close()
|
||||
self.ui.z_entry.deleteLater()
|
||||
self.ui.interpolation.close()
|
||||
self.ui.interpolation.deleteLater()
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>604</width>
|
||||
<height>166</height>
|
||||
<width>826</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@@ -17,20 +17,162 @@
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
<widget class="QGroupBox" name="groupBox_4">
|
||||
<property name="title">
|
||||
<string>Interpolation</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="ToggleSwitch" name="enforce_interpolation">
|
||||
<property name="toolTip">
|
||||
<string>Use the interpolation mode even for grid scans</string>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="toolTip">
|
||||
<string>Use the interpolation mode even for grid scans</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Enforce Interpolation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="QDoubleSpinBox" name="oversampling_factor">
|
||||
<property name="decimals">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>10.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QComboBox" name="interpolation">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>nearest</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>clough</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Oversampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Interpolation Method</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="validate_bec"/>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>16</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECColorMapWidget" name="color_map"/>
|
||||
<item alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="QGroupBox" name="groupBox_5">
|
||||
<property name="title">
|
||||
<string>General</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="ToggleSwitch" name="validate_bec"/>
|
||||
</item>
|
||||
<item row="3" column="3" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="BECColorMapWidget" name="color_map">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>Colormap</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
@@ -46,9 +188,6 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="x_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
@@ -56,8 +195,22 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="x_entry"/>
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@@ -75,9 +228,6 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="y_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
@@ -85,8 +235,22 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="y_entry"/>
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@@ -111,11 +275,22 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="z_entry"/>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="z_name"/>
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
@@ -126,9 +301,14 @@
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>device_line_edit</header>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SignalComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>signal_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
@@ -143,59 +323,109 @@
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
<tabstop>interpolation</tabstop>
|
||||
<tabstop>oversampling_factor</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>134</x>
|
||||
<y>95</y>
|
||||
<x>254</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>138</x>
|
||||
<y>128</y>
|
||||
<x>254</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>254</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>254</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>351</x>
|
||||
<y>91</y>
|
||||
<x>526</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>349</x>
|
||||
<y>121</y>
|
||||
<x>526</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>526</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>526</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>520</x>
|
||||
<y>98</y>
|
||||
<x>798</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>522</x>
|
||||
<y>127</y>
|
||||
<x>798</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>798</x>
|
||||
<y>226</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>798</x>
|
||||
<y>267</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
|
||||
@@ -1,204 +1,374 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>233</width>
|
||||
<height>427</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>427</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_apply">
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECColorMapWidget" name="color_map"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="validate_bec"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>X Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="x_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="x_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Y Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="y_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="y_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Z Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceLineEdit" name="z_name"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="z_entry"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>305</width>
|
||||
<height>629</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>629</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="button_apply">
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceLineEdit</class>
|
||||
<extends>QLineEdit</extends>
|
||||
<header>device_line_edit</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>156</x>
|
||||
<y>123</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>158</x>
|
||||
<y>157</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>116</x>
|
||||
<y>229</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>116</x>
|
||||
<y>251</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>textChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>clear()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>110</x>
|
||||
<y>326</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>110</x>
|
||||
<y>352</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECColorMapWidget" name="color_map"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Validate BEC</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="validate_bec">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="title">
|
||||
<string>X Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="x_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="x_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Y Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="y_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="y_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Z Device</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="DeviceComboBox" name="z_name">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="set_first_element_as_empty" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="SignalComboBox" name="z_entry">
|
||||
<property name="editable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>Signal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_4">
|
||||
<property name="title">
|
||||
<string>Interpolation</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_8">
|
||||
<property name="text">
|
||||
<string>Interpolation Method</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="interpolation">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>linear</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>nearest</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="text">
|
||||
<string>Enforce Interpolation</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1" alignment="Qt::AlignmentFlag::AlignRight">
|
||||
<widget class="ToggleSwitch" name="enforce_interpolation">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Oversampling</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="oversampling_factor">
|
||||
<property name="minimum">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>10.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>SignalComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>signal_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECColorMapWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_color_map_widget</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<tabstops>
|
||||
<tabstop>x_name</tabstop>
|
||||
<tabstop>y_name</tabstop>
|
||||
<tabstop>z_name</tabstop>
|
||||
<tabstop>button_apply</tabstop>
|
||||
<tabstop>x_entry</tabstop>
|
||||
<tabstop>y_entry</tabstop>
|
||||
<tabstop>z_entry</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>113</x>
|
||||
<y>178</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>110</x>
|
||||
<y>183</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>x_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>x_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>160</x>
|
||||
<y>178</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>159</x>
|
||||
<y>188</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>92</x>
|
||||
<y>278</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>92</x>
|
||||
<y>287</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>y_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>y_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>136</x>
|
||||
<y>277</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>135</x>
|
||||
<y>290</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>device_reset()</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>reset_selection()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>106</x>
|
||||
<y>376</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>112</x>
|
||||
<y>397</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>z_name</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>z_entry</receiver>
|
||||
<slot>set_device(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>164</x>
|
||||
<y>376</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>168</x>
|
||||
<y>389</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
|
||||
@@ -882,7 +882,6 @@ class ImageBase(PlotBase):
|
||||
enabled(bool): Whether to enable autorange.
|
||||
sync(bool): Whether to synchronize the autorange state across all layers.
|
||||
"""
|
||||
print(f"Setting autorange to {enabled}")
|
||||
for layer in self.layer_manager:
|
||||
if not layer.sync.autorange:
|
||||
continue
|
||||
@@ -914,7 +913,6 @@ class ImageBase(PlotBase):
|
||||
Args:
|
||||
mode(str): The autorange mode. Options are "max" or "mean".
|
||||
"""
|
||||
print(f"Setting autorange mode to {mode}")
|
||||
# for qt Designer
|
||||
if mode not in ["max", "mean"]:
|
||||
return
|
||||
@@ -936,7 +934,6 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
if not self.layer_manager:
|
||||
return
|
||||
print(f"Toggling autorange to {enabled} with mode {mode}")
|
||||
for layer in self.layer_manager:
|
||||
if layer.sync.autorange:
|
||||
layer.image.autorange = enabled
|
||||
@@ -1010,6 +1007,7 @@ class ImageBase(PlotBase):
|
||||
"""
|
||||
Cleanup the widget.
|
||||
"""
|
||||
self.toolbar.cleanup()
|
||||
|
||||
# Remove all ROIs
|
||||
rois = self.rois
|
||||
|
||||
@@ -1040,6 +1040,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.crosshair.update_markers()
|
||||
|
||||
def cleanup(self):
|
||||
self.toolbar.cleanup()
|
||||
self.unhook_crosshair()
|
||||
self.unhook_fps_monitor(delete_label=True)
|
||||
self.tick_item.cleanup()
|
||||
@@ -1049,7 +1050,6 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.axis_settings_dialog = None
|
||||
self.cleanup_pyqtgraph()
|
||||
self.round_plot_widget.close()
|
||||
self.toolbar.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
|
||||
|
||||
@@ -62,7 +62,9 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
ICON_NAME = "page_control"
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
super().__init__(
|
||||
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
|
||||
)
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
|
||||
@@ -89,7 +91,14 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
|
||||
# Progress‑bar state handling
|
||||
self._state = ProgressState.NORMAL
|
||||
self._state_colors = dict(PROGRESS_STATE_COLORS)
|
||||
# self._state_colors = dict(PROGRESS_STATE_COLORS)
|
||||
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
ProgressState.PAUSED: accent_colors.warning,
|
||||
ProgressState.INTERRUPTED: accent_colors.emergency,
|
||||
ProgressState.COMPLETED: accent_colors.success,
|
||||
}
|
||||
|
||||
# layout settings
|
||||
self._padding_left_right = 10
|
||||
@@ -127,6 +136,16 @@ class BECProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
return self._label_template
|
||||
|
||||
def apply_theme(self, theme=None):
|
||||
"""Apply the current theme to the progress bar."""
|
||||
accent_colors = get_accent_colors()
|
||||
self._state_colors = {
|
||||
ProgressState.NORMAL: accent_colors.default,
|
||||
ProgressState.PAUSED: accent_colors.warning,
|
||||
ProgressState.INTERRUPTED: accent_colors.emergency,
|
||||
ProgressState.COMPLETED: accent_colors.success,
|
||||
}
|
||||
|
||||
@label_template.setter
|
||||
def label_template(self, template):
|
||||
self._label_template = template
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from .scan_history_device_viewer import ScanHistoryDeviceViewer
|
||||
from .scan_history_metadata_viewer import ScanHistoryMetadataViewer
|
||||
from .scan_history_view import ScanHistoryView
|
||||
|
||||
__all__ = ["ScanHistoryDeviceViewer", "ScanHistoryMetadataViewer", "ScanHistoryView"]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Module for displaying scan history devices in a viewer widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import _StoredDataInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class SignalModel(QtCore.QAbstractListModel):
|
||||
"""Custom model for displaying scan history signals in a combo box."""
|
||||
|
||||
def __init__(self, parent=None, signals: dict[str, _StoredDataInfo] = None):
|
||||
super().__init__(parent)
|
||||
if signals is None:
|
||||
signals = {}
|
||||
self._signals: list[tuple[str, _StoredDataInfo]] = sorted(
|
||||
signals.items(), key=lambda x: -x[1].shape[0]
|
||||
)
|
||||
|
||||
@property
|
||||
def signals(self) -> list[tuple[str, _StoredDataInfo]]:
|
||||
"""Return the list of devices."""
|
||||
return self._signals
|
||||
|
||||
@signals.setter
|
||||
def signals(self, value: dict[str, _StoredDataInfo]):
|
||||
self.beginResetModel()
|
||||
self._signals = sorted(value.items(), key=lambda x: -x[1].shape[0])
|
||||
self.endResetModel()
|
||||
|
||||
def rowCount(self, parent=QtCore.QModelIndex()):
|
||||
return len(self._signals)
|
||||
|
||||
def data(self, index, role=QtCore.Qt.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
name, info = self.signals[index.row()]
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
return f"{name} {info.shape}" # fallback display
|
||||
elif role == QtCore.Qt.UserRole:
|
||||
return name
|
||||
elif role == QtCore.Qt.UserRole + 1:
|
||||
return info.shape
|
||||
return None
|
||||
|
||||
|
||||
# Custom delegate for better formatting
|
||||
class SignalDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Custom delegate for displaying device names and points in the combo box."""
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
name = index.data(QtCore.Qt.UserRole)
|
||||
points = index.data(QtCore.Qt.UserRole + 1)
|
||||
|
||||
painter.save()
|
||||
painter.drawText(
|
||||
option.rect.adjusted(5, 0, -5, 0), QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, name
|
||||
)
|
||||
painter.drawText(
|
||||
option.rect.adjusted(5, 0, -5, 0),
|
||||
QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight,
|
||||
str(points),
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
return QtCore.QSize(200, 24)
|
||||
|
||||
|
||||
class ScanHistoryDeviceViewer(BECWidget, QtWidgets.QWidget):
|
||||
"""ScanHistoryTree is a widget that displays the scan history in a tree format."""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
request_history_plot = QtCore.Signal(str, str, str) # (scan_id, device_name, signal_name)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
theme_update: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
# Current scan history message
|
||||
self.scan_history_msg: ScanHistoryMessage | None = None
|
||||
self._last_device_name: str | None = None
|
||||
self._last_signal_name: str | None = None
|
||||
# Init layout
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
self.setLayout(layout)
|
||||
# Init widgets
|
||||
self.device_combo = QtWidgets.QComboBox(parent=self)
|
||||
self.signal_combo = QtWidgets.QComboBox(parent=self)
|
||||
colors = get_accent_colors()
|
||||
self.request_plotting_button = QtWidgets.QPushButton(
|
||||
material_icon("play_arrow", size=(24, 24), color=colors.success),
|
||||
"Request Plotting",
|
||||
self,
|
||||
)
|
||||
self.signal_model = SignalModel(parent=self.signal_combo)
|
||||
self.signal_combo.setModel(self.signal_model)
|
||||
self.signal_combo.setItemDelegate(SignalDelegate())
|
||||
self._init_layout()
|
||||
# Connect signals
|
||||
self.request_plotting_button.clicked.connect(self._on_request_plotting_clicked)
|
||||
self.device_combo.currentTextChanged.connect(self._signal_combo_update)
|
||||
|
||||
def _init_layout(self):
|
||||
"""Initialize the layout for the device viewer."""
|
||||
main_layout = self.layout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
# horzizontal layout for device combo and signal combo boxes
|
||||
widget = QtWidgets.QWidget(self)
|
||||
hor_layout = QtWidgets.QHBoxLayout()
|
||||
hor_layout.setContentsMargins(0, 0, 0, 0)
|
||||
hor_layout.setSpacing(0)
|
||||
widget.setLayout(hor_layout)
|
||||
hor_layout.addWidget(self.device_combo)
|
||||
hor_layout.addWidget(self.signal_combo)
|
||||
main_layout.addWidget(widget)
|
||||
main_layout.addWidget(self.request_plotting_button)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_devices_from_scan_history(self, msg: dict, metadata: dict | None = None) -> None:
|
||||
"""Update the device combo box with the scan history message.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing device data.
|
||||
"""
|
||||
msg = ScanHistoryMessage(**msg)
|
||||
if metadata is not None:
|
||||
msg.metadata = metadata
|
||||
# Keep track of current device name
|
||||
self._last_device_name = self.device_combo.currentText()
|
||||
|
||||
current_signal_index = self.signal_combo.currentIndex()
|
||||
self._last_signal_name = self.signal_combo.model().data(
|
||||
self.signal_combo.model().index(current_signal_index, 0), QtCore.Qt.UserRole
|
||||
)
|
||||
# Update the scan history message
|
||||
self.scan_history_msg = msg
|
||||
self.device_combo.clear()
|
||||
self.device_combo.addItems(msg.stored_data_info.keys())
|
||||
index = self.device_combo.findData(self._last_device_name, role=QtCore.Qt.DisplayRole)
|
||||
if index != -1:
|
||||
self.device_combo.setCurrentIndex(index)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _signal_combo_update(self, device_name: str) -> None:
|
||||
"""Update the signal combo box based on the selected device."""
|
||||
if not self.scan_history_msg:
|
||||
logger.info("No scan history message available to update signals.")
|
||||
return
|
||||
if not device_name:
|
||||
return
|
||||
signal_data = self.scan_history_msg.stored_data_info.get(device_name, None)
|
||||
if signal_data is None:
|
||||
logger.info(f"No signal data found for device {device_name}.")
|
||||
return
|
||||
self.signal_model.signals = signal_data
|
||||
if self._last_signal_name is not None:
|
||||
# Try to restore the last selected signal
|
||||
index = self.signal_combo.findData(self._last_signal_name, role=QtCore.Qt.UserRole)
|
||||
if index != -1:
|
||||
self.signal_combo.setCurrentIndex(index)
|
||||
|
||||
@SafeSlot()
|
||||
def clear_view(self) -> None:
|
||||
"""Clear the device combo box."""
|
||||
self.scan_history_msg = None
|
||||
self.signal_model.signals = {}
|
||||
self.device_combo.clear()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_request_plotting_clicked(self):
|
||||
"""Handle the request plotting button click."""
|
||||
if self.scan_history_msg is None:
|
||||
logger.info("No scan history message available for plotting.")
|
||||
return
|
||||
device_name = self.device_combo.currentText()
|
||||
|
||||
signal_index = self.signal_combo.currentIndex()
|
||||
signal_name = self.signal_combo.model().data(
|
||||
self.device_combo.model().index(signal_index, 0), QtCore.Qt.UserRole
|
||||
)
|
||||
logger.info(
|
||||
f"Requesting plotting clicked: Scan ID:{self.scan_history_msg.scan_id}, device name: {device_name} with signal name: {signal_name}."
|
||||
)
|
||||
self.request_history_plot.emit(self.scan_history_msg.scan_id, device_name, signal_name)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
central_widget = QtWidgets.QWidget()
|
||||
main_window.setCentralWidget(central_widget)
|
||||
ly = QtWidgets.QVBoxLayout(central_widget)
|
||||
ly.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
viewer = ScanHistoryDeviceViewer()
|
||||
ly.addWidget(viewer)
|
||||
main_window.show()
|
||||
app.exec_()
|
||||
app.exec_()
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
|
||||
"""ScanHistoryView is a widget to display the metadata of a ScanHistoryMessage in a structured format."""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = True,
|
||||
scan_history_msg: ScanHistoryMessage | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the ScanHistoryMetadataViewer widget.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
client: The BEC client.
|
||||
config (ConnectionConfig, optional): The connection configuration.
|
||||
gui_id (str, optional): The GUI ID.
|
||||
theme_update (bool, optional): Whether to subscribe to theme updates. Defaults to True.
|
||||
scan_history_msg (ScanHistoryMessage, optional): The scan history message to display. Defaults
|
||||
"""
|
||||
super().__init__(
|
||||
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
self._scan_history_msg_labels = {
|
||||
"scan_id": "Scan ID",
|
||||
"dataset_number": "Dataset Nr",
|
||||
"file_path": "File Path",
|
||||
"start_time": "Start Time",
|
||||
"end_time": "End Time",
|
||||
"elapsed_time": "Elapsed Time",
|
||||
"exit_status": "Status",
|
||||
"scan_name": "Scan Name",
|
||||
"num_points": "Nr of Points",
|
||||
}
|
||||
self.setTitle("No Scan Selected")
|
||||
layout = QtWidgets.QGridLayout()
|
||||
self.setLayout(layout)
|
||||
self._init_grid_layout()
|
||||
self.scan_history_msg = scan_history_msg
|
||||
if scan_history_msg is not None:
|
||||
self.update_view(self.scan_history_msg.content, self.scan_history_msg.metadata)
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
"""Apply the theme to the widget."""
|
||||
colors = get_theme_palette()
|
||||
palette = QtGui.QPalette()
|
||||
palette.setColor(self.backgroundRole(), colors.midlight().color())
|
||||
self.setPalette(palette)
|
||||
|
||||
def _init_grid_layout(self):
|
||||
"""Initialize the layout of the widget."""
|
||||
layout: QtWidgets.QGridLayout = self.layout()
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setHorizontalSpacing(0)
|
||||
layout.setVerticalSpacing(0)
|
||||
layout.setColumnStretch(0, 0)
|
||||
layout.setColumnStretch(1, 1)
|
||||
layout.setColumnStretch(2, 0)
|
||||
|
||||
def setup_content_widget_label(self) -> None:
|
||||
"""Setup the labels for the content widget for the scan history view."""
|
||||
layout = self.layout()
|
||||
for row, k in enumerate(self._scan_history_msg_labels.keys()):
|
||||
v = self._scan_history_msg_labels[k]
|
||||
# Label for the key
|
||||
label = QtWidgets.QLabel(f"{v}:")
|
||||
layout.addWidget(label, row, 0)
|
||||
# Value field
|
||||
value_field = QtWidgets.QLabel("")
|
||||
value_field.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Preferred
|
||||
)
|
||||
layout.addWidget(value_field, row, 1)
|
||||
# Copy button
|
||||
copy_button = QtWidgets.QToolButton()
|
||||
copy_button.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum)
|
||||
copy_button.setContentsMargins(0, 0, 0, 0)
|
||||
copy_button.setStyleSheet("padding: 0px; margin: 0px; border: none;")
|
||||
copy_button.setIcon(material_icon(icon_name="content_copy", size=(16, 16)))
|
||||
copy_button.setToolTip("Copy to clipboard")
|
||||
copy_button.setVisible(False)
|
||||
copy_button.setEnabled(False)
|
||||
copy_button.clicked.connect(
|
||||
lambda _, field=value_field: QtWidgets.QApplication.clipboard().setText(
|
||||
field.text()
|
||||
)
|
||||
)
|
||||
layout.addWidget(copy_button, row, 2)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_view(self, msg: dict, metadata: dict | None = None) -> None:
|
||||
"""
|
||||
Update the view with the given ScanHistoryMessage.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The message containing scan metadata.
|
||||
"""
|
||||
msg = ScanHistoryMessage(**msg)
|
||||
if metadata is not None:
|
||||
msg.metadata = metadata
|
||||
if msg == self.scan_history_msg:
|
||||
return
|
||||
self.scan_history_msg = msg
|
||||
layout = self.layout()
|
||||
if layout.count() == 0:
|
||||
self.setup_content_widget_label()
|
||||
self.setTitle(f"Metadata - Scan {msg.scan_number}")
|
||||
for row, k in enumerate(self._scan_history_msg_labels.keys()):
|
||||
if k == "elapsed_time":
|
||||
value = (
|
||||
f"{(msg.end_time - msg.start_time):.3f}s"
|
||||
if msg.start_time and msg.end_time
|
||||
else None
|
||||
)
|
||||
else:
|
||||
value = getattr(msg, k, None)
|
||||
if k in ["start_time", "end_time"]:
|
||||
value = (
|
||||
datetime.fromtimestamp(value).strftime("%a %b %d %H:%M:%S %Y")
|
||||
if value
|
||||
else None
|
||||
)
|
||||
if value is None:
|
||||
logger.warning(f"ScanHistoryMessage missing value for {k} and msg {msg}.")
|
||||
continue
|
||||
layout.itemAtPosition(row, 1).widget().setText(str(value))
|
||||
if k in ["file_path", "scan_id"]: # Enable copy for file path and scan ID
|
||||
layout.itemAtPosition(row, 2).widget().setVisible(True)
|
||||
layout.itemAtPosition(row, 2).widget().setEnabled(True)
|
||||
else:
|
||||
layout.itemAtPosition(row, 2).widget().setText("")
|
||||
layout.itemAtPosition(row, 2).widget().setToolTip("")
|
||||
|
||||
@SafeSlot()
|
||||
def clear_view(self):
|
||||
"""
|
||||
Clear the view by resetting the labels and values.
|
||||
"""
|
||||
layout = self.layout()
|
||||
lauout_counts = layout.count()
|
||||
for i in range(lauout_counts):
|
||||
item = layout.itemAt(i)
|
||||
if item.widget():
|
||||
item.widget().close()
|
||||
item.widget().deleteLater()
|
||||
self.scan_history_msg = None
|
||||
self.setTitle("No Scan Selected")
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECHistoryManager(QtCore.QObject):
|
||||
"""History manager for scan history operations. This class
|
||||
is responsible for emitting signals when the scan history is updated.
|
||||
"""
|
||||
|
||||
# ScanHistoryMessage.model_dump() (dict)
|
||||
scan_history_updated = QtCore.Signal(dict)
|
||||
|
||||
def __init__(self, parent, client: BECClient):
|
||||
super().__init__(parent)
|
||||
self.client = client
|
||||
self._cb_id = self.client.callbacks.register(
|
||||
event_type=EventType.SCAN_HISTORY_UPDATE, callback=self._on_scan_history_update
|
||||
)
|
||||
|
||||
def refresh_scan_history(self) -> None:
|
||||
"""Refresh the scan history from the client."""
|
||||
for scan_id in self.client.history._scan_ids: # pylint: disable=protected-access
|
||||
history_msg = self.client.history._scan_data.get(scan_id, None)
|
||||
if history_msg is None:
|
||||
logger.info(f"Scan history message for scan_id {scan_id} not found.")
|
||||
continue
|
||||
self.scan_history_updated.emit(history_msg.model_dump())
|
||||
|
||||
def _on_scan_history_update(self, history_msg: ScanHistoryMessage) -> None:
|
||||
"""Handle scan history updates from the client."""
|
||||
self.scan_history_updated.emit(history_msg.model_dump())
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up the manager by disconnecting callbacks."""
|
||||
self.client.callbacks.remove(self._cb_id)
|
||||
self.scan_history_updated.disconnect()
|
||||
|
||||
|
||||
class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
"""ScanHistoryTree is a widget that displays the scan history in a tree format."""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
# ScanHistoryMessage.content, ScanHistoryMessage.metadata
|
||||
scan_selected = QtCore.Signal(dict, dict)
|
||||
no_scan_selected = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
max_length: int = 100,
|
||||
theme_update: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
colors = get_accent_colors()
|
||||
self.status_colors = {
|
||||
"closed": colors.success,
|
||||
"halted": colors.warning,
|
||||
"aborted": colors.emergency,
|
||||
}
|
||||
# self.status_colors = {"closed": "#00e676", "halted": "#ffca28", "aborted": "#ff5252"}
|
||||
self.column_header = ["Scan Nr", "Scan Name", "Status"]
|
||||
self.scan_history: list[ScanHistoryMessage] = [] # newest at index 0
|
||||
self.max_length = max_length # Maximum number of scan history entries to keep
|
||||
self.bec_scan_history_manager = BECHistoryManager(parent=self, client=self.client)
|
||||
self._set_policies()
|
||||
self.apply_theme()
|
||||
self.currentItemChanged.connect(self._current_item_changed)
|
||||
header = self.header()
|
||||
header.setToolTip(f"Last {self.max_length} scans in history.")
|
||||
self.bec_scan_history_manager.scan_history_updated.connect(self.update_history)
|
||||
self.refresh()
|
||||
|
||||
def _set_policies(self):
|
||||
"""Set the policies for the tree widget."""
|
||||
self.setColumnCount(len(self.column_header))
|
||||
self.setHeaderLabels(self.column_header)
|
||||
self.setRootIsDecorated(False) # allow expand arrow for per‑scan details
|
||||
self.setUniformRowHeights(True)
|
||||
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||
self.setIndentation(12)
|
||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
self.setAnimated(True)
|
||||
|
||||
header = self.header()
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
|
||||
for column in range(1, self.columnCount()):
|
||||
header.setSectionResizeMode(column, QtWidgets.QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
"""Apply the theme to the widget."""
|
||||
colors = get_accent_colors()
|
||||
self.status_colors = {
|
||||
"closed": colors.success,
|
||||
"halted": colors.warning,
|
||||
"aborted": colors.emergency,
|
||||
}
|
||||
self.repaint()
|
||||
|
||||
def _current_item_changed(
|
||||
self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem
|
||||
):
|
||||
"""
|
||||
Handle current item change events in the tree widget.
|
||||
|
||||
Args:
|
||||
current (QtWidgets.QTreeWidgetItem): The currently selected item.
|
||||
previous (QtWidgets.QTreeWidgetItem): The previously selected item.
|
||||
"""
|
||||
if not current:
|
||||
return
|
||||
index = self.indexOfTopLevelItem(current)
|
||||
self.scan_selected.emit(self.scan_history[index].content, self.scan_history[index].metadata)
|
||||
|
||||
@SafeSlot()
|
||||
def refresh(self):
|
||||
"""Refresh the scan history view."""
|
||||
while len(self.scan_history) > 0:
|
||||
self.remove_scan(index=0)
|
||||
self.bec_scan_history_manager.refresh_scan_history()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def update_history(self, msg_dump: dict):
|
||||
"""Update the scan history with new scan data."""
|
||||
msg = ScanHistoryMessage(**msg_dump)
|
||||
self.add_scan(msg)
|
||||
self.ensure_history_max_length()
|
||||
|
||||
def ensure_history_max_length(self) -> None:
|
||||
"""
|
||||
Method to ensure the scan history does not exceed the maximum length.
|
||||
If the length exceeds the maximum, it removes the oldest entry.
|
||||
This is called after adding a new scan to the history.
|
||||
"""
|
||||
while len(self.scan_history) > self.max_length:
|
||||
logger.warning(
|
||||
f"Removing oldest scan history entry to maintain max length of {self.max_length}."
|
||||
)
|
||||
self.remove_scan(index=-1)
|
||||
|
||||
def add_scan(self, msg: ScanHistoryMessage):
|
||||
"""
|
||||
Add a scan entry to the tree widget.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing scan details.
|
||||
"""
|
||||
if msg.stored_data_info is None:
|
||||
logger.info(
|
||||
f"Old scan history entry fo scan {msg.scan_id} without stored_data_info, skipping."
|
||||
)
|
||||
return
|
||||
if msg in self.scan_history:
|
||||
logger.info(f"Scan {msg.scan_id} already in history, skipping.")
|
||||
return
|
||||
self.scan_history.insert(0, msg)
|
||||
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
|
||||
color = QtGui.QColor(self.status_colors.get(msg.exit_status, "#b0bec5"))
|
||||
pix = QtGui.QPixmap(10, 10)
|
||||
pix.fill(QtCore.Qt.transparent)
|
||||
with QtGui.QPainter(pix) as p:
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawEllipse(0, 0, 10, 10)
|
||||
tree_item.setIcon(2, QtGui.QIcon(pix))
|
||||
tree_item.setForeground(2, QtGui.QBrush(color))
|
||||
for col in range(tree_item.columnCount()):
|
||||
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
|
||||
self.insertTopLevelItem(0, tree_item)
|
||||
tree_item.setExpanded(False)
|
||||
|
||||
def remove_scan(self, index: int):
|
||||
"""
|
||||
Remove a scan entry from the tree widget.
|
||||
We supoprt negative indexing where -1, -2, etc.
|
||||
|
||||
Args:
|
||||
index (int): The index of the scan entry to remove.
|
||||
"""
|
||||
if index < 0:
|
||||
index = len(self.scan_history) + index
|
||||
try:
|
||||
msg = self.scan_history.pop(index)
|
||||
self.no_scan_selected.emit()
|
||||
except IndexError:
|
||||
logger.warning(f"Invalid index {index} for removing scan entry from history.")
|
||||
return
|
||||
self.takeTopLevelItem(index)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget"""
|
||||
self.bec_scan_history_manager.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from bec_widgets.widgets.services.scan_history_browser.components import (
|
||||
ScanHistoryDeviceViewer,
|
||||
ScanHistoryMetadataViewer,
|
||||
)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QtWidgets.QApplication([])
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
central_widget = QtWidgets.QWidget()
|
||||
button = DarkModeButton()
|
||||
layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
main_window.setCentralWidget(central_widget)
|
||||
|
||||
# Create a ScanHistoryBrowser instance
|
||||
browser = ScanHistoryView()
|
||||
|
||||
# Create a ScanHistoryView instance
|
||||
view = ScanHistoryMetadataViewer()
|
||||
device_viewer = ScanHistoryDeviceViewer()
|
||||
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(browser)
|
||||
layout.addWidget(view)
|
||||
layout.addWidget(device_viewer)
|
||||
browser.scan_selected.connect(view.update_view)
|
||||
browser.scan_selected.connect(device_viewer.update_devices_from_scan_history)
|
||||
browser.no_scan_selected.connect(view.clear_view)
|
||||
browser.no_scan_selected.connect(device_viewer.clear_view)
|
||||
|
||||
main_window.show()
|
||||
app.exec_()
|
||||
@@ -0,0 +1,115 @@
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.widgets.services.scan_history_browser.components import (
|
||||
ScanHistoryDeviceViewer,
|
||||
ScanHistoryMetadataViewer,
|
||||
ScanHistoryView,
|
||||
)
|
||||
|
||||
|
||||
class ScanHistoryBrowser(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
ScanHistoryBrowser is a widget combining the scan history view, metadata viewer, and device viewer.
|
||||
|
||||
Target is to provide a popup view for the Waveform Widget to browse the scan history.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialize the ScanHistoryBrowser widget.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
client: The BEC client.
|
||||
config (ConnectionConfig, optional): The connection configuration.
|
||||
gui_id (str, optional): The GUI ID.
|
||||
theme_update (bool, optional): Whether to subscribe to theme updates. Defaults to False.
|
||||
"""
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
layout = QtWidgets.QHBoxLayout()
|
||||
self.setLayout(layout)
|
||||
|
||||
self.scan_history_view = ScanHistoryView(
|
||||
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
self.scan_history_metadata_viewer = ScanHistoryMetadataViewer(
|
||||
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
self.scan_history_device_viewer = ScanHistoryDeviceViewer(
|
||||
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
|
||||
)
|
||||
|
||||
self.init_layout()
|
||||
self.connect_signals()
|
||||
|
||||
def init_layout(self):
|
||||
"""Initialize compact layout for the widget."""
|
||||
# Add Scan history view
|
||||
layout: QtWidgets.QHBoxLayout = self.layout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.scan_history_view)
|
||||
# Add metadata and device viewers in a vertical layout
|
||||
widget = QtWidgets.QWidget(self)
|
||||
vertical_layout = QtWidgets.QVBoxLayout()
|
||||
vertical_layout.setContentsMargins(0, 0, 0, 0)
|
||||
vertical_layout.setSpacing(0)
|
||||
vertical_layout.addWidget(self.scan_history_metadata_viewer)
|
||||
vertical_layout.addWidget(self.scan_history_device_viewer)
|
||||
widget.setLayout(vertical_layout)
|
||||
# Add the vertical layout widget to the main layout
|
||||
layout.addWidget(widget)
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect signals from scan history components."""
|
||||
self.scan_history_view.scan_selected.connect(self.scan_history_metadata_viewer.update_view)
|
||||
self.scan_history_view.scan_selected.connect(
|
||||
self.scan_history_device_viewer.update_devices_from_scan_history
|
||||
)
|
||||
self.scan_history_view.no_scan_selected.connect(
|
||||
self.scan_history_metadata_viewer.clear_view
|
||||
)
|
||||
self.scan_history_view.no_scan_selected.connect(self.scan_history_device_viewer.clear_view)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication([])
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
|
||||
central_widget = QtWidgets.QWidget()
|
||||
button = DarkModeButton()
|
||||
layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
main_window.setCentralWidget(central_widget)
|
||||
# Create a ScanHistoryBrowser instance
|
||||
browser = ScanHistoryBrowser() # type: ignore
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(browser)
|
||||
main_window.setWindowTitle("Scan History Browser")
|
||||
main_window.resize(800, 400)
|
||||
main_window.show()
|
||||
app.exec_()
|
||||
@@ -10,7 +10,6 @@ from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal as QSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGroupBox,
|
||||
@@ -121,7 +120,9 @@ class ChoiceDialog(QDialog):
|
||||
self._signal_field.clear()
|
||||
|
||||
def accept(self):
|
||||
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
|
||||
self.accepted_output.emit(
|
||||
self._device_field.text(), self._signal_field.selected_signal_comp_name
|
||||
)
|
||||
return super().accept()
|
||||
|
||||
|
||||
|
||||
BIN
docs/assets/widget_screenshots/heatmap_widget.png
Normal file
BIN
docs/assets/widget_screenshots/heatmap_widget.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/user/widgets/heatmap/heatmap_fermat_scan.gif
Normal file
BIN
docs/user/widgets/heatmap/heatmap_fermat_scan.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
BIN
docs/user/widgets/heatmap/heatmap_grid_scan.gif
Normal file
BIN
docs/user/widgets/heatmap/heatmap_grid_scan.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 825 KiB |
106
docs/user/widgets/heatmap/heatmap_widget.md
Normal file
106
docs/user/widgets/heatmap/heatmap_widget.md
Normal file
@@ -0,0 +1,106 @@
|
||||
(user.widgets.heatmap_widget)=
|
||||
|
||||
# Heatmap widget
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The Heatmap widget is a specialized plotting tool designed for visualizing 2D grid data with color mapping for the z-axis. It excels at displaying data from grid scans or arbitrary step scans, automatically interpolating scattered data points into a coherent 2D image. Directly integrated with the `BEC` framework, it can display live data streams from scanning experiments within the current `BEC` session.
|
||||
|
||||
## Key Features:
|
||||
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
|
||||
- **Live Grid Scan Visualization**: Real-time plotting of grid scan data with automatic positioning and color mapping based on scan parameters.
|
||||
- **Dual Scan Support**: Handles both structured grid scans (with pre-allocated grids) and unstructured step scans (with interpolation).
|
||||
- **Intelligent Data Interpolation**: For arbitrary step scans, the widget automatically interpolates scattered (x, y, z) data points into a smooth 2D heatmap using various interpolation methods.
|
||||
- **Oversampling**: Supports oversampling to enhance the appearance of the heatmap, allowing for smoother transitions and better visual representation of data. Especially useful the for nearest-neighbor interpolation.
|
||||
- **Customizable Color Maps**: Wide variety of color maps available for data visualization, with support for both simple and full color bars.
|
||||
- **Real-time Image Processing**: Apply real-time processing techniques such as FFT and logarithmic scaling to enhance data visualization.
|
||||
- **Interactive Controls**: Comprehensive toolbar with settings for heatmap configuration, crosshair tools, mouse interaction, and data export capabilities.
|
||||
|
||||
|
||||
```{figure} ./heatmap_grid_scan.gif
|
||||
:width: 60%
|
||||
|
||||
Real-time heatmap visualization of a 2D grid scan showing motor positions and detector intensity
|
||||
```
|
||||
|
||||
```{figure} ./heatmap_fermat_scan.gif
|
||||
:width: 80%
|
||||
|
||||
Real-time heatmap visualization of an (not path-optimized) scan following Fermat's spiral pattern. On the left, the heatmap widget is shown with the oversampling option set to 10 and the interpolation method set to nearest neighbor. On the right, the scatter waveform widget is shown with the same data.
|
||||
```
|
||||
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - CLI
|
||||
|
||||
`HeatmapWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
|
||||
|
||||
## Example 1 - Visualizing Grid Scan Data
|
||||
|
||||
In this example, we demonstrate how to add a `HeatmapWidget` to visualize live data from a 2D grid scan with motor positions and detector readout.
|
||||
|
||||
```python
|
||||
# Add a new dock with HeatmapWidget
|
||||
dock_area = gui.new()
|
||||
heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
|
||||
|
||||
# Plot a heatmap with x and y motor positions and z detector signal
|
||||
heatmap_widget.plot(
|
||||
x_name='samx', # X-axis motor
|
||||
y_name='samy', # Y-axis motor
|
||||
z_name='bpm4i', # Z-axis detector signal
|
||||
color_map='plasma'
|
||||
)
|
||||
heatmap_widget.title = "Grid Scan - Sample Position vs BPM Intensity"
|
||||
```
|
||||
|
||||
## Example 2 - Step Scan with Custom Entries
|
||||
|
||||
This example shows how to visualize data from an arbitrary step scan by specifying custom data entries for each axis.
|
||||
|
||||
```python
|
||||
# Add a new dock with HeatmapWidget
|
||||
dock_area = gui.new()
|
||||
heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
|
||||
|
||||
# Plot heatmap with specific data entries
|
||||
heatmap_widget.plot(
|
||||
x_name='motor1',
|
||||
y_name='motor2',
|
||||
z_name='detector1',
|
||||
x_entry='RBV', # Use readback value for x
|
||||
y_entry='RBV', # Use readback value for y
|
||||
z_entry='value', # Use main value for z
|
||||
color_map='viridis',
|
||||
reload=True # Force reload of data
|
||||
)
|
||||
```
|
||||
|
||||
## Example 3 - Real-time Processing and Customization
|
||||
|
||||
The `Heatmap` widget provides real-time processing capabilities and extensive customization options for enhanced data visualization.
|
||||
|
||||
```python
|
||||
# Configure heatmap appearance and processing
|
||||
heatmap_widget.color_map = 'plasma'
|
||||
heatmap_widget.lock_aspect_ratio = True
|
||||
|
||||
# Apply real-time processing
|
||||
heatmap_widget.fft = True # Apply FFT to the data
|
||||
heatmap_widget.log = True # Use logarithmic scaling
|
||||
|
||||
# Configure color bar and range
|
||||
heatmap_widget.enable_full_colorbar = True
|
||||
heatmap_widget.v_min = 0
|
||||
heatmap_widget.v_max = 1000
|
||||
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.rst
|
||||
```
|
||||
````
|
||||
@@ -61,6 +61,14 @@ Display a 1D waveforms with a third device on the z-axis.
|
||||
Display signal from 2D detector.
|
||||
```
|
||||
|
||||
```{grid-item-card} Heatmap Widget
|
||||
:link: user.widgets.heatmap_widget
|
||||
:link-type: ref
|
||||
:img-top: /assets/widget_screenshots/heatmap_widget.png
|
||||
|
||||
Display 2D grid data with color mapping.
|
||||
```
|
||||
|
||||
```{grid-item-card} Motor Map Widget
|
||||
:link: user.widgets.motor_map
|
||||
:link-type: ref
|
||||
@@ -275,6 +283,7 @@ waveform/waveform_widget.md
|
||||
scatter_waveform/scatter_waveform.md
|
||||
multi_waveform/multi_waveform.md
|
||||
image/image_widget.md
|
||||
heatmap/heatmap_widget.md
|
||||
motor_map/motor_map.md
|
||||
scan_control/scan_control.md
|
||||
progress_bar/ring_progress_bar.md
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.24.0"
|
||||
version = "2.27.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"PySide6~=6.8.2",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"qtmonaco>=0.2.3",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from unittest import mock
|
||||
import json
|
||||
import time
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -83,3 +87,110 @@ def create_widget(qtbot, widget, *args, **kwargs):
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
return widget
|
||||
|
||||
|
||||
def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanHistoryMessage:
|
||||
"""
|
||||
Helper to create a history file with the given data.
|
||||
The data should contain readout groups, e.g.
|
||||
{
|
||||
"baseline": {"samx": {"samx": {"value": [1, 2, 3], "timestamp": [100, 200, 300]}},
|
||||
"monitored": {"bpm4i": {"bpm4i": {"value": [5, 6, 7], "timestamp": [101, 201, 301]}}},
|
||||
"async": {"async_device": {"async_device": {"value": [1, 2, 3], "timestamp": [11, 21, 31]}}},
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
with h5py.File(file_path, "w") as f:
|
||||
_metadata = f.create_group("entry/collection/metadata")
|
||||
_metadata.create_dataset("sample_name", data="test_sample")
|
||||
metadata_bec = f.create_group("entry/collection/metadata/bec")
|
||||
for key, value in metadata.items():
|
||||
if isinstance(value, dict):
|
||||
metadata_bec.create_group(key)
|
||||
for sub_key, sub_value in value.items():
|
||||
if isinstance(sub_value, list):
|
||||
sub_value = json.dumps(sub_value)
|
||||
metadata_bec[key].create_dataset(sub_key, data=sub_value)
|
||||
elif isinstance(sub_value, dict):
|
||||
for sub_sub_key, sub_sub_value in sub_value.items():
|
||||
sub_sub_group = metadata_bec[key].create_group(sub_key)
|
||||
if isinstance(sub_sub_value, list):
|
||||
sub_sub_value = json.dumps(sub_sub_value)
|
||||
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
|
||||
else:
|
||||
metadata_bec[key].create_dataset(sub_key, data=sub_value)
|
||||
else:
|
||||
metadata_bec.create_dataset(key, data=value)
|
||||
for group, devices in data.items():
|
||||
readout_group = f.create_group(f"entry/collection/readout_groups/{group}")
|
||||
|
||||
for device, device_data in devices.items():
|
||||
dev_group = f.create_group(f"entry/collection/devices/{device}")
|
||||
for signal, signal_data in device_data.items():
|
||||
signal_group = dev_group.create_group(signal)
|
||||
for signal_key, signal_values in signal_data.items():
|
||||
signal_group.create_dataset(signal_key, data=signal_values)
|
||||
|
||||
readout_group[device] = h5py.SoftLink(f"/entry/collection/devices/{device}")
|
||||
msg = messages.ScanHistoryMessage(
|
||||
scan_id=metadata["scan_id"],
|
||||
scan_name=metadata["scan_name"],
|
||||
exit_status=metadata["exit_status"],
|
||||
file_path=file_path,
|
||||
scan_number=metadata["scan_number"],
|
||||
dataset_number=metadata["dataset_number"],
|
||||
start_time=time.time(),
|
||||
end_time=time.time(),
|
||||
num_points=metadata["num_points"],
|
||||
request_inputs=metadata["request_inputs"],
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grid_scan_history_msg(tmpdir):
|
||||
x_grid, y_grid = np.meshgrid(np.linspace(-5, 5, 10), np.linspace(-5, 5, 10))
|
||||
|
||||
x_flat = x_grid.T.ravel()
|
||||
y_flat = y_grid.T.ravel()
|
||||
positions = np.vstack((x_flat, y_flat)).T
|
||||
num_points = len(positions)
|
||||
data = {
|
||||
"baseline": {"bpm1a": {"bpm1a": {"value": [1], "timestamp": [100]}}},
|
||||
"monitored": {
|
||||
"bpm4i": {
|
||||
"bpm4i": {
|
||||
"value": np.random.rand(num_points),
|
||||
"timestamp": np.random.rand(num_points),
|
||||
}
|
||||
},
|
||||
"samx": {"samx": {"value": x_flat, "timestamp": np.random.rand(num_points)}},
|
||||
"samy": {"samy": {"value": y_flat, "timestamp": np.random.rand(num_points)}},
|
||||
},
|
||||
"async": {
|
||||
"async_device": {
|
||||
"async_device": {
|
||||
"value": np.random.rand(num_points * 10),
|
||||
"timestamp": np.random.rand(num_points * 10),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
metadata = {
|
||||
"scan_id": "test_scan",
|
||||
"scan_name": "grid_scan",
|
||||
"scan_type": "step",
|
||||
"exit_status": "closed",
|
||||
"scan_number": 1,
|
||||
"dataset_number": 1,
|
||||
"request_inputs": {
|
||||
"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10],
|
||||
"kwargs": {"relative": True},
|
||||
},
|
||||
"positions": positions.tolist(),
|
||||
"num_points": num_points,
|
||||
}
|
||||
|
||||
file_path = str(tmpdir.join("scan_1.h5"))
|
||||
return create_history_file(file_path, data, metadata)
|
||||
|
||||
@@ -3,6 +3,7 @@ from unittest import mock
|
||||
import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
|
||||
|
||||
@@ -323,7 +324,43 @@ def test_heatmap_settings_popup_show_settings(heatmap_widget, qtbot):
|
||||
|
||||
# Check that the ui elements are correctly initialized
|
||||
assert dialog.widget.ui.color_map.colormap == heatmap_widget.color_map
|
||||
assert dialog.widget.ui.x_name.text() == heatmap_widget._image_config.x_device.name
|
||||
assert dialog.widget.ui.x_name.currentText() == heatmap_widget._image_config.x_device.name
|
||||
|
||||
dialog.reject()
|
||||
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)
|
||||
|
||||
|
||||
def test_heatmap_widget_reset(heatmap_widget):
|
||||
"""
|
||||
Test that the reset method clears the plot.
|
||||
"""
|
||||
heatmap_widget.scan_item = create_dummy_scan_item()
|
||||
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
|
||||
|
||||
heatmap_widget.reset()
|
||||
assert heatmap_widget._grid_index is None
|
||||
assert heatmap_widget.main_image.raw_data is None
|
||||
|
||||
|
||||
def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_history_msg, qtbot):
|
||||
"""
|
||||
Test that the update_plot method updates the plot with scan history.
|
||||
"""
|
||||
heatmap_widget.client.history = ScanHistory(heatmap_widget.client, False)
|
||||
heatmap_widget.client.history._scan_data[grid_scan_history_msg.scan_id] = grid_scan_history_msg
|
||||
heatmap_widget.client.history._scan_ids.append(grid_scan_history_msg.scan_id)
|
||||
heatmap_widget.client.queue.scan_storage.current_scan = None
|
||||
heatmap_widget.plot(
|
||||
x_name="samx",
|
||||
y_name="samy",
|
||||
z_name="bpm4i",
|
||||
x_entry="samx",
|
||||
y_entry="samy",
|
||||
z_entry="bpm4i",
|
||||
)
|
||||
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data is not None)
|
||||
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (10, 10))
|
||||
|
||||
heatmap_widget.enforce_interpolation = True
|
||||
heatmap_widget.oversampling_factor = 2.0
|
||||
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (20, 20))
|
||||
|
||||
39
tests/unit_tests/test_monaco_editor.py
Normal file
39
tests/unit_tests/test_monaco_editor.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monaco_widget(qtbot):
|
||||
widget = MonacoWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_monaco_widget_set_text(monaco_widget: MonacoWidget, qtbot):
|
||||
"""
|
||||
Test that the MonacoWidget can set text correctly.
|
||||
"""
|
||||
test_text = "Hello, Monaco!"
|
||||
monaco_widget.set_text(test_text)
|
||||
qtbot.waitUntil(lambda: monaco_widget.get_text() == test_text, timeout=1000)
|
||||
assert monaco_widget.get_text() == test_text
|
||||
|
||||
|
||||
def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot):
|
||||
"""
|
||||
Test that the MonacoWidget can be set to read-only mode.
|
||||
"""
|
||||
monaco_widget.set_text("Initial text")
|
||||
qtbot.waitUntil(lambda: monaco_widget.get_text() == "Initial text", timeout=1000)
|
||||
monaco_widget.set_readonly(True)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
monaco_widget.set_text("This should not change")
|
||||
|
||||
monaco_widget.set_readonly(False) # Set back to editable
|
||||
qtbot.wait(100)
|
||||
monaco_widget.set_text("Attempting to change text")
|
||||
qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000)
|
||||
assert monaco_widget.get_text() == "Attempting to change text"
|
||||
380
tests/unit_tests/test_scan_history_browser.py
Normal file
380
tests/unit_tests/test_scan_history_browser.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import ScanHistoryMessage, _StoredDataInfo
|
||||
from pytestqt import qtbot
|
||||
from qtpy import QtCore
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.services.scan_history_browser.components import (
|
||||
ScanHistoryDeviceViewer,
|
||||
ScanHistoryMetadataViewer,
|
||||
ScanHistoryView,
|
||||
)
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_msg():
|
||||
"""Fixture to create a mock ScanHistoryMessage."""
|
||||
yield ScanHistoryMessage(
|
||||
scan_id="test_scan",
|
||||
dataset_number=1,
|
||||
scan_number=1,
|
||||
scan_name="Test Scan",
|
||||
file_path="/path/to/scan",
|
||||
start_time=1751957906.3310962,
|
||||
end_time=1751957907.3310962, # 1s later
|
||||
exit_status="closed",
|
||||
num_points=10,
|
||||
request_inputs={"some_input": "value"},
|
||||
stored_data_info={
|
||||
"device2": {
|
||||
"device2_signal1": _StoredDataInfo(shape=(10,)),
|
||||
"device2_signal2": _StoredDataInfo(shape=(20,)),
|
||||
"device2_signal3": _StoredDataInfo(shape=(25,)),
|
||||
},
|
||||
"device3": {"device3_signal1": _StoredDataInfo(shape=(1,))},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_msg_2():
|
||||
"""Fixture to create a second mock ScanHistoryMessage."""
|
||||
yield ScanHistoryMessage(
|
||||
scan_id="test_scan_2",
|
||||
dataset_number=2,
|
||||
scan_number=2,
|
||||
scan_name="Test Scan 2",
|
||||
file_path="/path/to/scan_2",
|
||||
start_time=1751957908.3310962,
|
||||
end_time=1751957909.3310962, # 1s later
|
||||
exit_status="closed",
|
||||
num_points=5,
|
||||
request_inputs={"some_input": "new_value"},
|
||||
stored_data_info={
|
||||
"device0": {
|
||||
"device0_signal1": _StoredDataInfo(shape=(15,)),
|
||||
"device0_signal2": _StoredDataInfo(shape=(25,)),
|
||||
"device0_signal3": _StoredDataInfo(shape=(3,)),
|
||||
"device0_signal4": _StoredDataInfo(shape=(20,)),
|
||||
},
|
||||
"device2": {
|
||||
"device2_signal1": _StoredDataInfo(shape=(10,)),
|
||||
"device2_signal2": _StoredDataInfo(shape=(20,)),
|
||||
"device2_signal3": _StoredDataInfo(shape=(25,)),
|
||||
"device2_signal4": _StoredDataInfo(shape=(30,)),
|
||||
},
|
||||
"device1": {"device1_signal1": _StoredDataInfo(shape=(25,))},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_device_viewer(qtbot, mocked_client):
|
||||
widget = ScanHistoryDeviceViewer(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_metadata_viewer(qtbot, mocked_client):
|
||||
widget = ScanHistoryMetadataViewer(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_view(qtbot, mocked_client):
|
||||
widget = ScanHistoryView(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_history_browser(qtbot, mocked_client):
|
||||
"""Fixture to create a ScanHistoryBrowser widget."""
|
||||
widget = ScanHistoryBrowser(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_scan_history_device_viewer_receive_msg(
|
||||
qtbot, scan_history_device_viewer, scan_history_msg, scan_history_msg_2
|
||||
):
|
||||
"""Test updating devices from scan history."""
|
||||
# Update with first scan history message
|
||||
assert scan_history_device_viewer.scan_history_msg is None
|
||||
assert scan_history_device_viewer.signal_model.signals == []
|
||||
assert scan_history_device_viewer.signal_model.rowCount() == 0
|
||||
scan_history_device_viewer.update_devices_from_scan_history(
|
||||
scan_history_msg.content, scan_history_msg.metadata
|
||||
)
|
||||
assert scan_history_device_viewer.scan_history_msg == scan_history_msg
|
||||
assert scan_history_device_viewer.device_combo.currentText() == "device2"
|
||||
assert scan_history_device_viewer.signal_model.signals == [
|
||||
("device2_signal3", _StoredDataInfo(shape=(25,))),
|
||||
("device2_signal2", _StoredDataInfo(shape=(20,))),
|
||||
("device2_signal1", _StoredDataInfo(shape=(10,))),
|
||||
]
|
||||
current_index = scan_history_device_viewer.signal_combo.currentIndex()
|
||||
assert current_index == 0
|
||||
signal_name = scan_history_device_viewer.signal_combo.model().data(
|
||||
scan_history_device_viewer.signal_combo.model().index(current_index, 0), QtCore.Qt.UserRole
|
||||
)
|
||||
assert signal_name == "device2_signal3"
|
||||
|
||||
## Update of second message should not change the device if still available
|
||||
new_msg = scan_history_msg_2
|
||||
scan_history_device_viewer.update_devices_from_scan_history(new_msg.content, new_msg.metadata)
|
||||
assert scan_history_device_viewer.scan_history_msg == new_msg
|
||||
assert scan_history_device_viewer.signal_model.signals == [
|
||||
("device2_signal4", _StoredDataInfo(shape=(30,))),
|
||||
("device2_signal3", _StoredDataInfo(shape=(25,))),
|
||||
("device2_signal2", _StoredDataInfo(shape=(20,))),
|
||||
("device2_signal1", _StoredDataInfo(shape=(10,))),
|
||||
]
|
||||
assert scan_history_device_viewer.device_combo.currentText() == "device2"
|
||||
current_index = scan_history_device_viewer.signal_combo.currentIndex()
|
||||
assert current_index == 1
|
||||
signal_name = scan_history_device_viewer.signal_combo.model().data(
|
||||
scan_history_device_viewer.signal_combo.model().index(current_index, 0), QtCore.Qt.UserRole
|
||||
)
|
||||
assert signal_name == "device2_signal3"
|
||||
|
||||
|
||||
def test_scan_history_device_viewer_clear_view(qtbot, scan_history_device_viewer, scan_history_msg):
|
||||
"""Test clearing the device viewer."""
|
||||
scan_history_device_viewer.update_devices_from_scan_history(scan_history_msg.content)
|
||||
assert scan_history_device_viewer.scan_history_msg == scan_history_msg
|
||||
scan_history_device_viewer.clear_view()
|
||||
assert scan_history_device_viewer.scan_history_msg is None
|
||||
assert scan_history_device_viewer.device_combo.model().rowCount() == 0
|
||||
|
||||
|
||||
def test_scan_history_device_viewer_on_request_plotting_clicked(
|
||||
qtbot, scan_history_device_viewer, scan_history_msg
|
||||
):
|
||||
"""Test the request plotting button click."""
|
||||
scan_history_device_viewer.update_devices_from_scan_history(scan_history_msg.content)
|
||||
|
||||
plotting_callback_args = []
|
||||
|
||||
def plotting_callback(device_name, signal_name, msg):
|
||||
"""Callback to check if the request plotting signal is emitted."""
|
||||
plotting_callback_args.append((device_name, signal_name, msg))
|
||||
|
||||
scan_history_device_viewer.request_history_plot.connect(plotting_callback)
|
||||
qtbot.mouseClick(scan_history_device_viewer.request_plotting_button, QtCore.Qt.LeftButton)
|
||||
qtbot.waitUntil(lambda: len(plotting_callback_args) > 0, timeout=5000)
|
||||
# scan_id
|
||||
assert plotting_callback_args[0][0] == scan_history_msg.scan_id
|
||||
# device_name
|
||||
assert plotting_callback_args[0][1] in scan_history_msg.stored_data_info.keys()
|
||||
# signal_name
|
||||
assert (
|
||||
plotting_callback_args[0][2]
|
||||
in scan_history_msg.stored_data_info[plotting_callback_args[0][1]].keys()
|
||||
)
|
||||
|
||||
|
||||
def test_scan_history_metadata_viewer_receive_msg(
|
||||
qtbot, scan_history_metadata_viewer, scan_history_msg
|
||||
):
|
||||
"""Test the initialization of ScanHistoryMetadataViewer."""
|
||||
assert scan_history_metadata_viewer.scan_history_msg is None
|
||||
assert scan_history_metadata_viewer.title() == "No Scan Selected"
|
||||
scan_history_metadata_viewer.update_view(scan_history_msg.content)
|
||||
assert scan_history_metadata_viewer.scan_history_msg == scan_history_msg
|
||||
assert scan_history_metadata_viewer.title() == f"Metadata - Scan {scan_history_msg.scan_number}"
|
||||
for row, k in enumerate(scan_history_metadata_viewer._scan_history_msg_labels.keys()):
|
||||
if k == "elapsed_time":
|
||||
scan_history_metadata_viewer.layout().itemAtPosition(row, 1).widget().text() == "1.000s"
|
||||
if k == "scan_name":
|
||||
scan_history_metadata_viewer.layout().itemAtPosition(
|
||||
row, 1
|
||||
).widget().text() == "Test Scan"
|
||||
|
||||
|
||||
def test_scan_history_metadata_viewer_clear_view(
|
||||
qtbot, scan_history_metadata_viewer, scan_history_msg
|
||||
):
|
||||
"""Test clearing the metadata viewer."""
|
||||
scan_history_metadata_viewer.update_view(scan_history_msg.content)
|
||||
assert scan_history_metadata_viewer.scan_history_msg == scan_history_msg
|
||||
scan_history_metadata_viewer.clear_view()
|
||||
assert scan_history_metadata_viewer.scan_history_msg is None
|
||||
assert scan_history_metadata_viewer.title() == "No Scan Selected"
|
||||
|
||||
|
||||
def test_scan_history_view(qtbot, scan_history_view, scan_history_msg):
|
||||
"""Test the initialization of ScanHistoryView."""
|
||||
assert scan_history_view.scan_history == []
|
||||
assert scan_history_view.topLevelItemCount() == 0
|
||||
header = scan_history_view.headerItem()
|
||||
assert [header.text(i) for i in range(header.columnCount())] == [
|
||||
"Scan Nr",
|
||||
"Scan Name",
|
||||
"Status",
|
||||
]
|
||||
|
||||
|
||||
def test_scan_history_view_add_remove_scan(qtbot, scan_history_view, scan_history_msg):
|
||||
"""Test adding a scan to the ScanHistoryView."""
|
||||
scan_history_view.update_history(scan_history_msg.model_dump())
|
||||
assert len(scan_history_view.scan_history) == 1
|
||||
assert scan_history_view.scan_history[0] == scan_history_msg
|
||||
assert scan_history_view.topLevelItemCount() == 1
|
||||
tree_item = scan_history_view.topLevelItem(0)
|
||||
tree_item.text(0) == str(scan_history_msg.scan_number)
|
||||
tree_item.text(1) == scan_history_msg.scan_name
|
||||
tree_item.text(2) == ""
|
||||
|
||||
# remove scan
|
||||
def remove_callback(msg):
|
||||
"""Callback to check if the no_scan_selected signal is emitted."""
|
||||
assert msg == scan_history_msg
|
||||
|
||||
scan_history_view.remove_scan(0)
|
||||
assert len(scan_history_view.scan_history) == 0
|
||||
assert scan_history_view.topLevelItemCount() == 0
|
||||
|
||||
|
||||
def test_scan_history_view_current_scan_item_changed(
|
||||
qtbot, scan_history_view, scan_history_msg, scan_history_device_viewer
|
||||
):
|
||||
"""Test the current scan item changed signal."""
|
||||
scan_history_view.update_history(scan_history_msg.model_dump())
|
||||
scan_history_msg.scan_id = "test_scan_2"
|
||||
scan_history_view.update_history(scan_history_msg.model_dump())
|
||||
scan_history_msg.scan_id = "test_scan_3"
|
||||
scan_history_view.update_history(scan_history_msg.model_dump())
|
||||
assert len(scan_history_view.scan_history) == 3
|
||||
|
||||
def scan_selected_callback(msg):
|
||||
"""Callback to check if the scan_selected signal is emitted."""
|
||||
return msg == scan_history_msg
|
||||
|
||||
scan_history_view.scan_selected.connect(scan_selected_callback)
|
||||
|
||||
qtbot.mouseClick(
|
||||
scan_history_view.viewport(),
|
||||
QtCore.Qt.LeftButton,
|
||||
pos=scan_history_view.visualItemRect(scan_history_view.topLevelItem(0)).center(),
|
||||
)
|
||||
|
||||
|
||||
def test_scan_history_view_refresh(qtbot, scan_history_view, scan_history_msg, scan_history_msg_2):
|
||||
"""Test the refresh method of ScanHistoryView."""
|
||||
scan_history_view.update_history(scan_history_msg.model_dump())
|
||||
scan_history_view.update_history(scan_history_msg_2.model_dump())
|
||||
assert len(scan_history_view.scan_history) == 2
|
||||
with mock.patch.object(
|
||||
scan_history_view.bec_scan_history_manager, "refresh_scan_history"
|
||||
) as mock_refresh:
|
||||
scan_history_view.refresh()
|
||||
mock_refresh.assert_called_once()
|
||||
assert len(scan_history_view.scan_history) == 0
|
||||
assert scan_history_view.topLevelItemCount() == 0
|
||||
|
||||
|
||||
def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, scan_history_msg_2):
|
||||
"""Test the initialization of ScanHistoryBrowser."""
|
||||
assert isinstance(scan_history_browser.scan_history_view, ScanHistoryView)
|
||||
assert isinstance(scan_history_browser.scan_history_metadata_viewer, ScanHistoryMetadataViewer)
|
||||
assert isinstance(scan_history_browser.scan_history_device_viewer, ScanHistoryDeviceViewer)
|
||||
|
||||
# Add 2 scans to the history browser, new item will be added to the top
|
||||
scan_history_browser.scan_history_view.update_history(scan_history_msg.model_dump())
|
||||
scan_history_browser.scan_history_view.update_history(scan_history_msg_2.model_dump())
|
||||
|
||||
assert len(scan_history_browser.scan_history_view.scan_history) == 2
|
||||
# Click on first scan item history to select it
|
||||
qtbot.mouseClick(
|
||||
scan_history_browser.scan_history_view.viewport(),
|
||||
QtCore.Qt.LeftButton,
|
||||
pos=scan_history_browser.scan_history_view.visualItemRect(
|
||||
scan_history_browser.scan_history_view.topLevelItem(0)
|
||||
).center(),
|
||||
)
|
||||
assert scan_history_browser.scan_history_view.currentIndex().row() == 0
|
||||
|
||||
# Both metadata and device viewers should be updated with the first scan
|
||||
qtbot.waitUntil(
|
||||
lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg
|
||||
== scan_history_msg_2,
|
||||
timeout=2000,
|
||||
)
|
||||
qtbot.waitUntil(
|
||||
lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg
|
||||
== scan_history_msg_2,
|
||||
timeout=2000,
|
||||
)
|
||||
|
||||
# TODO #771 ; Multiple clicks to the QTreeView item fail, but only in the CI, not locally.
|
||||
# Click on second scan item history to select it
|
||||
# qtbot.mouseClick(
|
||||
# scan_history_browser.scan_history_view.viewport(),
|
||||
# QtCore.Qt.LeftButton,
|
||||
# pos=scan_history_browser.scan_history_view.visualItemRect(
|
||||
# scan_history_browser.scan_history_view.topLevelItem(1)
|
||||
# ).center(),
|
||||
# )
|
||||
# assert scan_history_browser.scan_history_view.currentIndex().row() == 1
|
||||
|
||||
# # Both metadata and device viewers should be updated with the first scan
|
||||
# qtbot.waitUntil(
|
||||
# lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg
|
||||
# == scan_history_msg,
|
||||
# timeout=2000,
|
||||
# )
|
||||
# qtbot.waitUntil(
|
||||
# lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg
|
||||
# == scan_history_msg,
|
||||
# timeout=2000,
|
||||
# )
|
||||
|
||||
callback_args = []
|
||||
|
||||
def plotting_callback(device_name, signal_name, msg):
|
||||
"""Callback to check if the request plotting signal is emitted."""
|
||||
# device_name should be the first device
|
||||
callback_args.append((device_name, signal_name, msg))
|
||||
|
||||
scan_history_browser.scan_history_device_viewer.request_history_plot.connect(plotting_callback)
|
||||
# Test emit plotting request
|
||||
qtbot.mouseClick(
|
||||
scan_history_browser.scan_history_device_viewer.request_plotting_button,
|
||||
QtCore.Qt.LeftButton,
|
||||
)
|
||||
qtbot.waitUntil(lambda: len(callback_args) > 0, timeout=5000)
|
||||
assert callback_args[0][0] == scan_history_msg_2.scan_id
|
||||
device_name = callback_args[0][1]
|
||||
signal_name = callback_args[0][2]
|
||||
assert device_name in scan_history_msg_2.stored_data_info.keys()
|
||||
assert signal_name in scan_history_msg_2.stored_data_info[device_name].keys()
|
||||
|
||||
# Test clearing the view, removing both scans
|
||||
scan_history_browser.scan_history_view.remove_scan(-1)
|
||||
scan_history_browser.scan_history_view.remove_scan(-1)
|
||||
|
||||
assert len(scan_history_browser.scan_history_view.scan_history) == 0
|
||||
assert scan_history_browser.scan_history_view.topLevelItemCount() == 0
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg is None,
|
||||
timeout=2000,
|
||||
)
|
||||
qtbot.waitUntil(
|
||||
lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg is None,
|
||||
timeout=2000,
|
||||
)
|
||||
@@ -142,6 +142,7 @@ def test_choose_signal_dialog_sends_choices(signal_label: SignalLabel, qtbot):
|
||||
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
|
||||
dialog._device_field.dev["test device"] = MagicMock()
|
||||
dialog._device_field.setText("test device")
|
||||
dialog._signal_field._signals = [("test signal", {"component_name": "test signal"})]
|
||||
dialog._signal_field.addItem("test signal")
|
||||
dialog._signal_field.setCurrentIndex(0)
|
||||
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
|
||||
@@ -154,6 +155,7 @@ def test_dialog_handler_updates_devices(signal_label: SignalLabel, qtbot):
|
||||
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
|
||||
dialog._device_field.dev["flux_capacitor"] = MagicMock()
|
||||
dialog._device_field.setText("flux_capacitor")
|
||||
dialog._signal_field._signals = [("spin_speed", {"component_name": "spin_speed"})]
|
||||
dialog._signal_field.addItem("spin_speed")
|
||||
dialog._signal_field.setCurrentIndex(0)
|
||||
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
|
||||
|
||||
Reference in New Issue
Block a user