mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-11 11:10:53 +02:00
Compare commits
22 Commits
feat/proce
...
scratch/de
| Author | SHA1 | Date | |
|---|---|---|---|
| a275c5a3b1 | |||
| b12e6432d6 | |||
| b802beec69 | |||
| 6c40df4380 | |||
| dc8ae5a85d | |||
| 2d41a158bc | |||
| c2a6964875 | |||
| 774ca08cb3 | |||
| 4606bbd570 | |||
| b2ed9d42f7 | |||
| d799691e12 | |||
| 6848a9e20b | |||
|
|
bd5aafc052 | ||
| b4f6f5aa8b | |||
| 14d51b8016 | |||
|
|
e94554b471 | ||
| 7e0e391888 | |||
| 53e5ec42b8 | |||
|
|
0e49828a23 | ||
| 278d8de058 | |||
|
|
cb4c2beed4 | ||
| 4382d5c9b1 |
102
.github/workflows/ci.yml
vendored
102
.github/workflows/ci.yml
vendored
@@ -1,19 +1,19 @@
|
||||
name: Full CI
|
||||
on:
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
description: "Branch of BEC Widgets to install"
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
description: "Branch of BEC Core to install"
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -25,60 +25,58 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check_pr_status:
|
||||
uses: ./.github/workflows/check_pr.yml
|
||||
# check_pr_status:
|
||||
# uses: ./.github/workflows/check_pr.yml
|
||||
|
||||
formatter:
|
||||
needs: check_pr_status
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/formatter.yml
|
||||
# formatter:
|
||||
# needs: check_pr_status
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/formatter.yml
|
||||
|
||||
unit-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
# unit-test:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/pytest.yml
|
||||
# with:
|
||||
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
# secrets:
|
||||
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
unit-test-matrix:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
# unit-test-matrix:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/pytest-matrix.yml
|
||||
# with:
|
||||
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/generate-cli-check.yml
|
||||
# generate-cli-test:
|
||||
# needs: [check_pr_status, formatter]
|
||||
# if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
# uses: ./.github/workflows/generate-cli-check.yml
|
||||
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
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 }}
|
||||
# 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 }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
# 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 }}
|
||||
|
||||
6
.github/workflows/end2end-conda.yml
vendored
6
.github/workflows/end2end-conda.yml
vendored
@@ -48,12 +48,14 @@ jobs:
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
pip install pytest-repeat
|
||||
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end/user_interaction/test_user_interaction_e2e.py
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
path: ./bec/logs/*.log
|
||||
retention-days: 7
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,6 +1,51 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.2.0 (2026-03-11)
|
||||
|
||||
### Features
|
||||
|
||||
- **curve, waveform**: Add dap_parameters for lmfit customization in DAP requests
|
||||
([`14d51b8`](https://github.com/bec-project/bec_widgets/commit/14d51b80169f5a060dd24287f3a6db9a4b41275a))
|
||||
|
||||
- **waveform**: Composite DAP with multiple models
|
||||
([`b4f6f5a`](https://github.com/bec-project/bec_widgets/commit/b4f6f5aa8bcd0f6091610e8f839ea265c87575e0))
|
||||
|
||||
|
||||
## v3.1.4 (2026-03-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **profile_utils**: Renamed to fetch widgets settings
|
||||
([`53e5ec4`](https://github.com/bec-project/bec_widgets/commit/53e5ec42b8b33397af777f418fbd8601628226a6))
|
||||
|
||||
### Build System
|
||||
|
||||
- Increased minimal version of bec and bec qthemes
|
||||
([`7e0e391`](https://github.com/bec-project/bec_widgets/commit/7e0e391888f2ee4e8528ccb3938e36da4c32f146))
|
||||
|
||||
|
||||
## v3.1.3 (2026-03-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **monaco_dock**: Optimization, removal of QTimer, eventFilter replaced by signal/slot
|
||||
([`278d8de`](https://github.com/bec-project/bec_widgets/commit/278d8de058c2f5c6c9aa7317e1026651d7a4acd3))
|
||||
|
||||
|
||||
## v3.1.2 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dock_area**: Remove old AdvancedDockArea references
|
||||
([`4382d5c`](https://github.com/bec-project/bec_widgets/commit/4382d5c9b1fdac4048692eec53dd43127d67467b))
|
||||
|
||||
### Build System
|
||||
|
||||
- **deps**: Update isort requirement
|
||||
([`8463b32`](https://github.com/bec-project/bec_widgets/commit/8463b327923f853cfa1462bc22be1e83d4fd9a75))
|
||||
|
||||
|
||||
## v3.1.1 (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -133,4 +133,8 @@ if __name__ == "__main__":
|
||||
exclusive=True,
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -4,10 +4,9 @@ import re
|
||||
|
||||
import markdown
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import ProcedureRequestMessage
|
||||
from bec_lib.script_executor import upload_script
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QKeySequence, QShortcut # type: ignore
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
@@ -17,7 +16,6 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.control.procedure_control.procedure_panel import ProcedurePanel
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||
@@ -127,9 +125,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
self._current_script_id: str | None = None
|
||||
self.script_editor_tab = None
|
||||
|
||||
self.procedures = ProcedurePanel(self)
|
||||
self.procedures.setObjectName("Procedure Control")
|
||||
|
||||
self._initialize_layout()
|
||||
|
||||
# Connect editor signals
|
||||
@@ -188,16 +183,24 @@ class DeveloperWidget(DockAreaWidget):
|
||||
)
|
||||
|
||||
# Plotting area on the right with signature help tabbed alongside
|
||||
_r_panel = {
|
||||
"closable": False,
|
||||
"floatable": False,
|
||||
"movable": False,
|
||||
"return_dock": True,
|
||||
"title_buttons": {"float": True},
|
||||
}
|
||||
self.plotting_dock = self.new(self.plotting_ads, where="right", **_r_panel)
|
||||
self.signature_dock = self.new(self.signature_help, **_r_panel, tab_with=self.plotting_dock)
|
||||
self.procedure_dock = self.new(self.procedures, **_r_panel, tab_with=self.plotting_dock)
|
||||
self.plotting_ads_dock = self.new(
|
||||
self.plotting_ads,
|
||||
where="right",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
title_buttons={"float": True},
|
||||
)
|
||||
self.signature_dock = self.new(
|
||||
self.signature_help,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
tab_with=self.plotting_ads_dock,
|
||||
return_dock=True,
|
||||
title_buttons={"float": False, "close": False},
|
||||
)
|
||||
|
||||
self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3])
|
||||
|
||||
@@ -230,16 +233,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
submit_action = MaterialIconAction(
|
||||
icon_name="animated_images",
|
||||
tooltip="Run current file as a BEC procedure",
|
||||
label_text="Run on server",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
submit_action.action.triggered.connect(self.on_submit_procedure)
|
||||
self.toolbar.components.add_safe("run_proc", submit_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop",
|
||||
tooltip="Stop current execution",
|
||||
@@ -253,7 +246,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
execution_bundle.add_action("run_proc")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
@@ -313,41 +305,24 @@ class DeveloperWidget(DockAreaWidget):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
def _try_upload(self) -> str | None:
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor."""
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return None
|
||||
if not isinstance(widget := self.script_editor_tab.widget(), MonacoWidget):
|
||||
return None
|
||||
return
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
if widget.modified:
|
||||
# Save the file before execution if there are unsaved changes
|
||||
self.monaco.save_file()
|
||||
if widget.modified:
|
||||
# If still modified, user likely cancelled save dialog
|
||||
return None
|
||||
return upload_script(self.client.connector, widget.get_text())
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor."""
|
||||
return
|
||||
self.current_script_id = upload_script(self.client.connector, widget.get_text())
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
if (script_id := self._try_upload()) is not None:
|
||||
self.current_script_id = script_id
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_submit_procedure(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor as a procedure."""
|
||||
if (script_id := self._try_upload()) is not None:
|
||||
self.current_script_id = script_id
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.procedure_request(),
|
||||
ProcedureRequestMessage(
|
||||
identifier="run_script", args_kwargs=((self.current_script_id,), {})
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
|
||||
@@ -187,7 +187,7 @@ class BECDockArea(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
@@ -1141,7 +1141,7 @@ class DockAreaView(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
@@ -1386,7 +1386,7 @@ class DockAreaWidget(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
@@ -3192,7 +3192,7 @@ class MonacoDock(RPCBase):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
@@ -4787,188 +4787,6 @@ class PositionerGroup(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class ProcedurePanel(RPCBase):
|
||||
@rpc_call
|
||||
def new(
|
||||
self,
|
||||
widget: "QWidget | str",
|
||||
*,
|
||||
closable: "bool" = True,
|
||||
floatable: "bool" = True,
|
||||
movable: "bool" = True,
|
||||
start_floating: "bool" = False,
|
||||
floating_state: "Mapping[str, object] | None" = None,
|
||||
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
|
||||
on_close: "Callable[[CDockWidget, QWidget], None] | None" = None,
|
||||
tab_with: "CDockWidget | QWidget | str | None" = None,
|
||||
relative_to: "CDockWidget | QWidget | str | None" = None,
|
||||
return_dock: "bool" = False,
|
||||
show_title_bar: "bool | None" = None,
|
||||
title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None,
|
||||
show_settings_action: "bool | None" = False,
|
||||
promote_central: "bool" = False,
|
||||
dock_icon: "QIcon | None" = None,
|
||||
apply_widget_icon: "bool" = True,
|
||||
object_name: "str | None" = None,
|
||||
**widget_kwargs,
|
||||
) -> "QWidget | CDockWidget | BECWidget":
|
||||
"""
|
||||
Create a new widget (or reuse an instance) and add it as a dock.
|
||||
|
||||
Args:
|
||||
widget(QWidget | str): Instance or registered widget type string.
|
||||
closable(bool): Whether the dock is closable.
|
||||
floatable(bool): Whether the dock is floatable.
|
||||
movable(bool): Whether the dock is movable.
|
||||
start_floating(bool): Whether to start the dock floating.
|
||||
floating_state(Mapping | None): Optional floating geometry metadata to apply when floating.
|
||||
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
|
||||
``relative_to`` is provided without an explicit value).
|
||||
on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget).
|
||||
tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside.
|
||||
relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor.
|
||||
When supplied and ``where`` is ``None``, the new dock inherits the
|
||||
anchor's current dock area.
|
||||
return_dock(bool): When True, return the created dock instead of the widget.
|
||||
show_title_bar(bool | None): Explicitly show or hide the dock area's title bar.
|
||||
title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should
|
||||
remain visible. Provide a mapping of button names (``"float"``,
|
||||
``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans,
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default),
|
||||
the widget's ``ICON_NAME`` attribute is used when available.
|
||||
apply_widget_icon(bool): When False, skip automatically resolving the icon from
|
||||
the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
**widget_kwargs: Additional keyword arguments passed to the widget constructor
|
||||
when creating by type name.
|
||||
|
||||
Returns:
|
||||
The widget instance by default, or the created `CDockWidget` when `return_dock` is True.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def dock_map(self) -> "dict[str, CDockWidget]":
|
||||
"""
|
||||
Return the dock widgets map as dictionary with names as keys.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def dock_list(self) -> "list[CDockWidget]":
|
||||
"""
|
||||
Return the list of dock widgets.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]":
|
||||
"""
|
||||
Return a dictionary mapping widget names to their corresponding widgets.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]":
|
||||
"""
|
||||
Return a list of widgets contained in the dock area.
|
||||
|
||||
Args:
|
||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach_all(self):
|
||||
"""
|
||||
Re-attach floating docks back into the dock manager.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete_all(self):
|
||||
"""
|
||||
Delete all docks and their associated widgets.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete(self, object_name: "str") -> "bool":
|
||||
"""
|
||||
Remove a widget from the dock area by its object name.
|
||||
|
||||
Args:
|
||||
object_name: The object name of the widget to remove.
|
||||
|
||||
Returns:
|
||||
bool: True if the widget was found and removed, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If no widget with the given object name is found.
|
||||
|
||||
Example:
|
||||
>>> dock_area.delete("my_widget")
|
||||
True
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_layout_ratios(
|
||||
self,
|
||||
*,
|
||||
horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None,
|
||||
vertical: "Sequence[float] | Mapping[int | str, float] | None" = None,
|
||||
splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None,
|
||||
) -> "None":
|
||||
"""
|
||||
Adjust splitter ratios in the dock layout.
|
||||
|
||||
Args:
|
||||
horizontal: Weights applied to every horizontal splitter encountered.
|
||||
vertical: Weights applied to every vertical splitter encountered.
|
||||
splitter_overrides: Optional overrides targeting specific splitters identified
|
||||
by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based
|
||||
indices following the splitter hierarchy, starting from the root splitter.
|
||||
|
||||
Example:
|
||||
To build three columns with custom per-column ratios::
|
||||
|
||||
area.set_layout_ratios(
|
||||
horizontal=[1, 2, 1], # column widths
|
||||
splitter_overrides={
|
||||
0: [1, 2], # column 0 (two rows)
|
||||
1: [3, 2, 1], # column 1 (three rows)
|
||||
2: [1], # column 2 (single row)
|
||||
},
|
||||
)
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def describe_layout(self) -> "list[dict[str, Any]]":
|
||||
"""
|
||||
Return metadata describing splitter paths, orientations, and contained docks.
|
||||
|
||||
Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def print_layout_structure(self) -> "None":
|
||||
"""
|
||||
Pretty-print the current splitter paths to stdout.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None":
|
||||
"""
|
||||
Promote an existing dock to be the dock manager's central widget.
|
||||
|
||||
Args:
|
||||
dock(CDockWidget | QWidget | str): Dock reference to promote.
|
||||
"""
|
||||
|
||||
|
||||
class RectangularROI(RPCBase):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
|
||||
@@ -6431,7 +6249,8 @@ class Waveform(RPCBase):
|
||||
signal_y: "str | None" = None,
|
||||
color: "str | None" = None,
|
||||
label: "str | None" = None,
|
||||
dap: "str | None" = None,
|
||||
dap: "str | list[str] | None" = None,
|
||||
dap_parameters: "dict | list | lmfit.Parameters | None | object" = None,
|
||||
scan_id: "str | None" = None,
|
||||
scan_number: "int | None" = None,
|
||||
**kwargs,
|
||||
@@ -6453,9 +6272,14 @@ class Waveform(RPCBase):
|
||||
signal_y(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
dap(str | list[str]): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
the same string as the LMFit model name, or a list of model names to build a composite.
|
||||
dap_parameters(dict | list | lmfit.Parameters | None): Optional lmfit parameter overrides sent to
|
||||
the DAP server. For a single model: values can be numeric (interpreted as fixed parameters)
|
||||
or dicts like `{"value": 1.0, "vary": False}`. For composite models (dap is list), use either
|
||||
a list aligned to the model list (each item is a param dict), or a dict of
|
||||
`{ "ModelName": { "param": {...} } }` when model names are unique.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -6469,9 +6293,10 @@ class Waveform(RPCBase):
|
||||
def add_dap_curve(
|
||||
self,
|
||||
device_label: "str",
|
||||
dap_name: "str",
|
||||
dap_name: "str | list[str]",
|
||||
color: "str | None" = None,
|
||||
dap_oversample: "int" = 1,
|
||||
dap_parameters: "dict | list | lmfit.Parameters | None" = None,
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
@@ -6481,9 +6306,11 @@ class Waveform(RPCBase):
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
dap_name(str | list[str]): The name of the DAP model to use, or a list of model
|
||||
names to build a composite model.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
dap_parameters(dict | list | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
|
||||
**kwargs
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -118,6 +118,9 @@ class RPCServer:
|
||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||
try:
|
||||
logger.info(
|
||||
f"Processing RPC instruction: {msg['action']} with request_id: {request_id}. Parameters: {msg.get('parameter')}"
|
||||
)
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
@@ -257,6 +260,10 @@ class RPCServer:
|
||||
else:
|
||||
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
|
||||
|
||||
logger.info(
|
||||
f"Launching new dock area with name: {name} and startup_profile: {startup_profile}"
|
||||
)
|
||||
|
||||
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
|
||||
@@ -296,6 +303,9 @@ class RPCServer:
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
except RegistryNotReadyError:
|
||||
logger.info(
|
||||
f"Object not registered yet for RPC request {request_id}, retrying serialization after {retry_delay} ms"
|
||||
)
|
||||
try:
|
||||
self._rpc_singleshot_repeats[request_id] += retry_delay
|
||||
QTimer.singleShot(
|
||||
|
||||
@@ -1315,7 +1315,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||
|
||||
@@ -136,10 +136,7 @@ class BECDockArea(DockAreaWidget):
|
||||
self._profile_management_enabled = enable_profile_management
|
||||
self._startup_profile = self._normalize_startup_profile(startup_profile)
|
||||
super().__init__(
|
||||
parent,
|
||||
default_add_direction=default_add_direction,
|
||||
title="Advanced Dock Area",
|
||||
**kwargs,
|
||||
parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
|
||||
)
|
||||
|
||||
# Initialize mode property first (before toolbar setup)
|
||||
@@ -168,7 +165,7 @@ class BECDockArea(DockAreaWidget):
|
||||
|
||||
# State manager
|
||||
self.state_manager = WidgetStateManager(
|
||||
self, serialize_from_root=True, root_id="AdvancedDockArea"
|
||||
self, serialize_from_root=True, root_id="BECDockArea"
|
||||
)
|
||||
|
||||
# Developer mode state
|
||||
@@ -304,7 +301,7 @@ class BECDockArea(DockAreaWidget):
|
||||
or a sequence of button names to hide.
|
||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||
such as `AdvancedDockArea` override the default to ``True``.
|
||||
such as `BECDockArea` override the default to ``True``.
|
||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||
central widget (useful for editor stacks or other root content).
|
||||
object_name(str | None): Optional object name to assign to the created widget.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Utilities for managing AdvancedDockArea profiles stored in INI files.
|
||||
Utilities for managing BECDockArea profiles stored in INI files.
|
||||
|
||||
Policy:
|
||||
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
|
||||
@@ -36,12 +36,12 @@ ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
||||
|
||||
def module_profiles_dir() -> str:
|
||||
"""
|
||||
Return the built-in AdvancedDockArea profiles directory bundled with the module.
|
||||
Return the built-in BECDockArea profiles directory bundled with the module.
|
||||
|
||||
Returns:
|
||||
str: Absolute path of the read-only module profiles directory.
|
||||
"""
|
||||
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
|
||||
return os.path.join(MODULE_PATH, "containers", "dock_area", "profiles")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -115,12 +115,12 @@ def _settings_profiles_root() -> str:
|
||||
str: Absolute path to the profiles root. The directory is created if missing.
|
||||
"""
|
||||
client = BECClient()
|
||||
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
|
||||
bec_widgets_settings = client._service_config.config.get("widgets_settings")
|
||||
bec_widgets_setting_path = (
|
||||
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
|
||||
)
|
||||
default_path = os.path.join(bec_widgets_setting_path, "profiles")
|
||||
root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
|
||||
root = os.path.expanduser(os.environ.get("BECWIDGETS_PROFILE_DIR", default_path))
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return root
|
||||
|
||||
@@ -138,7 +138,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
||||
"""
|
||||
base = os.path.join(_settings_profiles_root(), segment)
|
||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||
path = os.path.join(base, ns) if ns else base
|
||||
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
||||
return
|
||||
|
||||
self.target_widget.save_profile_dialog()
|
||||
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
|
||||
# BECDockArea will emit profile_changed which will trigger table refresh,
|
||||
# but ensure the UI stays in sync even if the signal is delayed.
|
||||
self.render_table()
|
||||
current = getattr(self.target_widget, "_current_profile_name", None)
|
||||
|
||||
@@ -118,7 +118,7 @@ class ProfileComboBox(QComboBox):
|
||||
|
||||
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||
Creates a workspace toolbar bundle for BECDockArea.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
@@ -171,7 +171,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
||||
|
||||
class WorkspaceConnection(BundleConnection):
|
||||
"""
|
||||
Connection class for workspace actions in AdvancedDockArea.
|
||||
Connection class for workspace actions in BECDockArea.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import operator
|
||||
from functools import partial, reduce
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import (
|
||||
ProcedureExecutionMessage,
|
||||
ProcedureQNotifMessage,
|
||||
ProcedureRequestMessage,
|
||||
)
|
||||
from bec_lib.procedures.helper import FrontendProcedureHelper
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from qtpy.QtCore import QSize, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_icon = partial(material_icon, size=(20, 20), convert_to_pixmap=False, filled=False)
|
||||
|
||||
_ActionTypes = Literal["abort", "delete", "resubmit"]
|
||||
|
||||
|
||||
class _BaseConfig(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
actions: set[_ActionTypes]
|
||||
child_actions: set[_ActionTypes]
|
||||
actions_column: int = 3
|
||||
params_column: int = 2
|
||||
helper: FrontendProcedureHelper
|
||||
tree: QTreeWidget
|
||||
active_queue: bool = False
|
||||
|
||||
|
||||
class _QueueConfig(BaseModel):
|
||||
queue: str
|
||||
base: _BaseConfig
|
||||
msgs: list[ProcedureExecutionMessage]
|
||||
|
||||
|
||||
class _ItemConfig(BaseModel):
|
||||
base: _BaseConfig
|
||||
msg: ProcedureExecutionMessage
|
||||
|
||||
|
||||
class _ActionItem(QTreeWidgetItem):
|
||||
ABORT_BUTTON_COLOR = DELETE_BUTTON_COLOR = "#CC181E"
|
||||
RESUBMIT_BUTTON_COLOR = "#2266BB"
|
||||
ACTION_TYPE: Literal["parent", "child"] = "child"
|
||||
|
||||
def __init__(self, parent, strings: list[str], config: _BaseConfig):
|
||||
super().__init__(parent, strings)
|
||||
self._tree = config.tree
|
||||
self._config = config
|
||||
self._init_actions()
|
||||
|
||||
def _init_actions(self):
|
||||
"""Create the actions widget in the given column."""
|
||||
self.actions_widget = QWidget()
|
||||
actions_layout = QHBoxLayout(self.actions_widget)
|
||||
actions_layout.setContentsMargins(0, 0, 0, 0)
|
||||
actions_layout.setSpacing(0)
|
||||
|
||||
def button(icon, color, slot, tooltip):
|
||||
button = QToolButton(self.actions_widget)
|
||||
setattr(self, icon, button)
|
||||
icon = _icon(icon, color=color)
|
||||
button.setIcon(icon)
|
||||
button.clicked.connect(slot)
|
||||
actions_layout.addWidget(button)
|
||||
button.setToolTip(tooltip)
|
||||
|
||||
actions = (
|
||||
self._config.actions if self.ACTION_TYPE == "parent" else self._config.child_actions
|
||||
)
|
||||
if "abort" in actions:
|
||||
button("cancel_presentation", self.ABORT_BUTTON_COLOR, self._abort_self, "abort")
|
||||
if "delete" in actions:
|
||||
button("delete", self.DELETE_BUTTON_COLOR, self._delete_self, "delete")
|
||||
if "resubmit" in actions:
|
||||
button("autorenew", self.RESUBMIT_BUTTON_COLOR, self._resubmit_self, "resubmit")
|
||||
|
||||
self._tree.setItemWidget(self, self._config.actions_column, self.actions_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def _abort_self(self): ...
|
||||
@SafeSlot()
|
||||
def _delete_self(self): ...
|
||||
@SafeSlot()
|
||||
def _resubmit_self(self): ...
|
||||
|
||||
|
||||
class JobItem(_ActionItem):
|
||||
def __init__(self, parent, strings: list[str], config: _ItemConfig):
|
||||
super().__init__(parent, strings, config.base)
|
||||
self._msg = config.msg
|
||||
self._init_params_display()
|
||||
|
||||
def queue(self):
|
||||
return self._msg.queue
|
||||
|
||||
def _init_params_display(self):
|
||||
self.setText(self._config.params_column, self._short_params_text())
|
||||
self.setToolTip(self._config.params_column, self._long_params_html())
|
||||
|
||||
def _short_params_text(self):
|
||||
a, k = self._msg.args_kwargs
|
||||
args = f"{a}, " if a else ""
|
||||
kwargs = f"{k}".strip("{}") if k else ""
|
||||
return args + kwargs
|
||||
|
||||
def _long_params_html(self):
|
||||
a, k = self._msg.args_kwargs
|
||||
args = "<b>Positional arguments:</b><br>" + ", ".join(str(arg) for arg in a) if a else ""
|
||||
kwargs = (
|
||||
reduce(
|
||||
operator.add,
|
||||
(f" {k}: {v}<br>" for k, v in k.items()),
|
||||
"<b>Keyword arguments:</b><br>",
|
||||
)
|
||||
if k
|
||||
else ""
|
||||
)
|
||||
return args + kwargs
|
||||
|
||||
@SafeSlot()
|
||||
def _abort_self(self):
|
||||
self._config.helper.request.abort_execution(self._msg.execution_id)
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_self(self):
|
||||
self._config.helper.request.clear_unhandled_execution(self._msg.execution_id)
|
||||
|
||||
@SafeSlot()
|
||||
def _resubmit_self(self):
|
||||
self._config.helper.request.clear_unhandled_execution(self._msg.execution_id)
|
||||
self._config.helper.request.procedure(
|
||||
identifier=self._msg.identifier,
|
||||
queue=self._msg.queue,
|
||||
args_kwargs=self._msg.args_kwargs,
|
||||
)
|
||||
|
||||
|
||||
class QueueItem(_ActionItem):
|
||||
ACTION_TYPE = "parent"
|
||||
|
||||
def __init__(self, parent, strings: list[str], config: _QueueConfig):
|
||||
super().__init__(parent, strings, config.base)
|
||||
self._queue = config.queue
|
||||
self.update(config.msgs)
|
||||
|
||||
def clear(self):
|
||||
for i in reversed(range(self.childCount())):
|
||||
self.removeChild(self.child(i))
|
||||
|
||||
def update(self, msgs: list[ProcedureExecutionMessage]):
|
||||
if self._config.active_queue:
|
||||
active = self._config.helper.get.running_procedures()
|
||||
for msg in active:
|
||||
if msg.queue == self._queue:
|
||||
JobItem(
|
||||
self, [msg.identifier, "RUNNING"], _ItemConfig(base=self._config, msg=msg)
|
||||
)
|
||||
for msg in msgs:
|
||||
JobItem(
|
||||
self,
|
||||
[msg.identifier, "PENDING" if self._config.active_queue else "ABORTED"],
|
||||
_ItemConfig(base=self._config, msg=msg),
|
||||
)
|
||||
|
||||
def queue(self):
|
||||
return self._queue
|
||||
|
||||
@SafeSlot()
|
||||
def _abort_self(self):
|
||||
self._config.helper.request.abort_queue(self._queue)
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_self(self):
|
||||
self._config.helper.request.clear_unhandled_queue(self._queue)
|
||||
|
||||
|
||||
class CategoryItem(QTreeWidgetItem):
|
||||
def __init__(self, parent, strings: list[str], config: _BaseConfig):
|
||||
super().__init__(parent, strings)
|
||||
self._queues: dict[str, QueueItem] = {}
|
||||
self._tree: QTreeWidget = parent
|
||||
self._config = config
|
||||
|
||||
def update(self, queue: str, msgs: list[ProcedureExecutionMessage]):
|
||||
if (queue_item := self._queues.get(queue)) is not None:
|
||||
queue_item.clear()
|
||||
queue_item.update(msgs)
|
||||
if queue_item.childCount() == 0:
|
||||
self.removeChild(queue_item)
|
||||
del self._queues[queue]
|
||||
elif msgs:
|
||||
self._queues[queue] = QueueItem(
|
||||
self, [queue], _QueueConfig(base=self._config, queue=queue, msgs=msgs)
|
||||
)
|
||||
self._queues[queue].setExpanded(True)
|
||||
|
||||
|
||||
class ProcedureControl(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
queue_selected = Signal(str)
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
|
||||
config = config or ConnectionConfig()
|
||||
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
self._conn = self.bec_dispatcher.client.connector
|
||||
self._helper = FrontendProcedureHelper(self._conn)
|
||||
self._setup_ui()
|
||||
self.bec_dispatcher.connect_slot(self._update, MessageEndpoints.procedure_queue_notif())
|
||||
self._init_queues()
|
||||
self._content.itemSelectionChanged.connect(self.on_selection_changed)
|
||||
|
||||
def on_selection_changed(self):
|
||||
selected_items = self._content.selectedItems()
|
||||
if len(selected_items) != 1:
|
||||
self.queue_selected.emit("")
|
||||
return
|
||||
if isinstance((item := selected_items[0]), (QueueItem, JobItem)):
|
||||
self.queue_selected.emit(item.queue())
|
||||
return
|
||||
self.queue_selected.emit("")
|
||||
|
||||
@SafeSlot(ProcedureQNotifMessage, dict)
|
||||
def _update(self, msg: dict | ProcedureQNotifMessage, _):
|
||||
msg = ProcedureQNotifMessage.model_validate(msg)
|
||||
if msg.queue_type == "execution":
|
||||
cat_to_update = self._active_queues
|
||||
read_queue = self._helper.get.exec_queue
|
||||
else:
|
||||
cat_to_update = self._unhandled_queues
|
||||
read_queue = self._helper.get.unhandled_queue
|
||||
cat_to_update.update(msg.queue_name, read_queue(msg.queue_name))
|
||||
|
||||
def _setup_ui(self):
|
||||
self._layout = QVBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._content = QTreeWidget()
|
||||
self._content.setAlternatingRowColors(True)
|
||||
self._content.setHeaderLabels(["name", "status", "params", "actions"])
|
||||
self._layout.addWidget(self._content)
|
||||
|
||||
config = partial(_BaseConfig, helper=self._helper, tree=self._content, actions_column=3)
|
||||
|
||||
self._active_queues = CategoryItem(
|
||||
self._content,
|
||||
["active queues"],
|
||||
config(actions={"abort"}, child_actions={"abort"}, active_queue=True),
|
||||
)
|
||||
self._content.addTopLevelItem(self._active_queues)
|
||||
self._active_queues.setExpanded(True)
|
||||
|
||||
self._unhandled_queues = CategoryItem(
|
||||
self._content,
|
||||
["unhandled queues"],
|
||||
config(actions={"delete"}, child_actions={"delete", "resubmit"}),
|
||||
)
|
||||
self._content.addTopLevelItem(self._unhandled_queues)
|
||||
self._active_queues.setExpanded(True)
|
||||
|
||||
def _init_queues(self):
|
||||
for queue in self._helper.get.active_and_pending_queue_names():
|
||||
self._active_queues.update(queue, self._helper.get.exec_queue(queue))
|
||||
for queue in self._helper.get.queue_names("unhandled"):
|
||||
self._unhandled_queues.update(queue, self._helper.get.unhandled_queue(queue))
|
||||
|
||||
|
||||
class ProcedureSubmissionOptionsDialog(QDialog):
|
||||
"""
|
||||
Dialog to customize procedure options
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Procedure execution options")
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(600, 800)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the dialog UI with ScanControl widget and buttons."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create the scan control widget
|
||||
|
||||
# Create dialog buttons
|
||||
button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self)
|
||||
|
||||
# Create custom buttons with appropriate text
|
||||
insert_button = QPushButton("Insert")
|
||||
cancel_button = QPushButton("Cancel")
|
||||
|
||||
button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Connect button signals
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
def accept(self):
|
||||
"""Override accept to generate code before closing."""
|
||||
super().accept()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ProcedureControl()
|
||||
widget.setFixedWidth(800)
|
||||
widget.setFixedHeight(800)
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,125 +0,0 @@
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.procedures.helper import FrontendProcedureHelper
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ProcedureLogs(BECWidget, QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
|
||||
config = config or ConnectionConfig()
|
||||
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
|
||||
self._conn = self.bec_dispatcher.client.connector
|
||||
self._queue: str | None = None
|
||||
self._helper = FrontendProcedureHelper(self._conn)
|
||||
self._setup_ui()
|
||||
|
||||
@SafeSlot()
|
||||
def _update_selection_box(self):
|
||||
self._selection_box.clear()
|
||||
self._available_streams = self._helper.get.log_queue_names()
|
||||
self._selection_box.addItems(self._available_streams)
|
||||
|
||||
def _setup_ui(self):
|
||||
self._layout = QVBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._setup_tools()
|
||||
self._setup_display()
|
||||
|
||||
def _setup_tools(self):
|
||||
self.tools = QWidget(self)
|
||||
self._tools_layout = QHBoxLayout()
|
||||
self._tools_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.tools.setLayout(self._tools_layout)
|
||||
self._selection_box = QComboBox()
|
||||
self._update_selection_box()
|
||||
self._selection_box.setCurrentIndex(-1)
|
||||
self._selection_box.currentTextChanged.connect(self.set_queue)
|
||||
self._refresh_button = QToolButton()
|
||||
self._refresh_button.setIcon(material_icon("refresh", convert_to_pixmap=False))
|
||||
self._tools_layout.addWidget(QLabel("Select logs stream: "))
|
||||
self._tools_layout.addWidget(self._selection_box)
|
||||
self._tools_layout.addWidget(self._refresh_button)
|
||||
self._refresh_button.clicked.connect(self._update_selection_box)
|
||||
self._layout.addWidget(self.tools)
|
||||
|
||||
def _setup_display(self):
|
||||
self.widget = QTextEdit(lineWrapMode=QTextEdit.LineWrapMode.NoWrap, readOnly=True)
|
||||
font = QFont("Courier New")
|
||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
self.widget.setFont(font)
|
||||
self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self._layout.addWidget(self.widget)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _update(self, msg, _):
|
||||
self.widget.append(msg.get("data").strip())
|
||||
|
||||
def _init_content(self):
|
||||
self.widget.setText("")
|
||||
if self._queue is None:
|
||||
return
|
||||
if msgs := self._conn.xread(MessageEndpoints.procedure_logs(self._queue), from_start=True):
|
||||
self.widget.append("\n".join(msg.get("data").data.strip() for msg in msgs))
|
||||
|
||||
@SafeSlot()
|
||||
def clear_selection_box(self, *_, **__):
|
||||
self._selection_box.setCurrentIndex(-1)
|
||||
|
||||
@SafeSlot(None)
|
||||
@SafeSlot(str)
|
||||
def set_queue(self, queue: str | None):
|
||||
if queue == "":
|
||||
return
|
||||
self.queue = queue
|
||||
self._selection_box.setCurrentIndex(-1)
|
||||
|
||||
@SafeProperty(str)
|
||||
def queue(self) -> str | None:
|
||||
return self._queue
|
||||
|
||||
@queue.setter
|
||||
def queue(self, queue: str | None) -> None:
|
||||
if self._queue == queue:
|
||||
return
|
||||
if self._queue is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._update, MessageEndpoints.procedure_logs(self._queue)
|
||||
)
|
||||
self._queue = queue or None
|
||||
if self._queue is not None:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self._update, MessageEndpoints.procedure_logs(self._queue)
|
||||
)
|
||||
self._init_content()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ProcedureLogs()
|
||||
widget.queue = "primary"
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,35 +0,0 @@
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.control.procedure_control.procedure_control import ProcedureControl
|
||||
from bec_widgets.widgets.control.procedure_control.procedure_logs import ProcedureLogs
|
||||
|
||||
|
||||
class ProcedurePanel(DockAreaWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.procedure_control = ProcedureControl(parent=self)
|
||||
self.procedure_control.setObjectName("Procedure Queue Control")
|
||||
self.procedure_logs = ProcedureLogs(parent=self)
|
||||
self.procedure_logs.setObjectName("Procedure Logs")
|
||||
|
||||
_dock_kwargs = {
|
||||
"closable": False,
|
||||
"movable": False,
|
||||
"floatable": False,
|
||||
"title_buttons": {"float": False, "close": False, "menu": False},
|
||||
}
|
||||
self.new(self.procedure_control, **_dock_kwargs)
|
||||
self.new(self.procedure_logs, where="bottom", **_dock_kwargs)
|
||||
|
||||
self.procedure_control.queue_selected.connect(self.procedure_logs.set_queue)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = ProcedurePanel()
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -6,7 +6,7 @@ from typing import Any, cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.macro_update_handler import has_executable_code
|
||||
from qtpy.QtCore import QEvent, QTimer, Signal
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||
@@ -36,12 +36,12 @@ class MonacoDock(DockAreaWidget):
|
||||
**kwargs,
|
||||
)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
|
||||
self.dock_manager.installEventFilter(self)
|
||||
self._last_focused_editor: CDockWidget | None = None
|
||||
self.focused_editor.connect(self._on_last_focused_editor_changed)
|
||||
initial_editor = self.add_editor()
|
||||
if isinstance(initial_editor, CDockWidget):
|
||||
self.last_focused_editor = initial_editor
|
||||
self._install_manager_scan_and_fix_guards()
|
||||
|
||||
def _create_editor_widget(self) -> MonacoWidget:
|
||||
"""Create a configured Monaco editor widget."""
|
||||
@@ -73,7 +73,8 @@ class MonacoDock(DockAreaWidget):
|
||||
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
||||
self.save_enabled.emit(widget.modified)
|
||||
|
||||
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
|
||||
@staticmethod
|
||||
def _update_tab_title_for_modification(dock: CDockWidget, modified: bool):
|
||||
"""Update the tab title to show modification status with a dot indicator."""
|
||||
current_title = dock.windowTitle()
|
||||
|
||||
@@ -98,14 +99,12 @@ class MonacoDock(DockAreaWidget):
|
||||
return
|
||||
|
||||
active_sig = signatures[signature.get("activeSignature", 0)]
|
||||
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
|
||||
|
||||
# Get signature label and documentation
|
||||
label = active_sig.get("label", "")
|
||||
doc_obj = active_sig.get("documentation", {})
|
||||
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
|
||||
|
||||
# Format the markdown output
|
||||
# Format the Markdown output
|
||||
markdown = f"```python\n{label}\n```\n\n{documentation}"
|
||||
self.signature_help.emit(markdown)
|
||||
|
||||
@@ -156,9 +155,10 @@ class MonacoDock(DockAreaWidget):
|
||||
if self.last_focused_editor is dock:
|
||||
self.last_focused_editor = None
|
||||
# After topology changes, make sure single-tab areas get a plus button
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
self._scan_and_fix_areas()
|
||||
|
||||
def reset_widget(self, widget: MonacoWidget):
|
||||
@staticmethod
|
||||
def reset_widget(widget: MonacoWidget):
|
||||
"""
|
||||
Reset the given Monaco editor widget to its initial state.
|
||||
|
||||
@@ -193,23 +193,23 @@ class MonacoDock(DockAreaWidget):
|
||||
# pylint: disable=protected-access
|
||||
area._monaco_plus_btn = plus_btn
|
||||
|
||||
def _scan_and_fix_areas(self):
|
||||
def _install_manager_scan_and_fix_guards(self) -> None:
|
||||
"""
|
||||
Track ADS structural changes to trigger scan and fix of dock areas for plus button injection.
|
||||
"""
|
||||
self.dock_manager.dockAreaCreated.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.dockWidgetAdded.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.stateRestored.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.restoringState.connect(self._scan_and_fix_areas)
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._scan_and_fix_areas)
|
||||
self._scan_and_fix_areas()
|
||||
|
||||
def _scan_and_fix_areas(self, *_arg):
|
||||
# Find all dock areas under this manager and ensure each single-tab area has a plus button
|
||||
areas = self.dock_manager.findChildren(CDockAreaWidget)
|
||||
for a in areas:
|
||||
self._ensure_area_plus(a)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
# Track dock manager events
|
||||
if obj is self.dock_manager and event.type() in (
|
||||
QEvent.Type.ChildAdded,
|
||||
QEvent.Type.ChildRemoved,
|
||||
QEvent.Type.LayoutRequest,
|
||||
):
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def add_editor(
|
||||
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
|
||||
) -> CDockWidget:
|
||||
@@ -258,7 +258,7 @@ class MonacoDock(DockAreaWidget):
|
||||
if area_widget is not None:
|
||||
self._ensure_area_plus(area_widget)
|
||||
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
self._scan_and_fix_areas()
|
||||
self.last_focused_editor = dock
|
||||
return dock
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ class DeviceSignal(BaseModel):
|
||||
|
||||
device: str
|
||||
signal: str
|
||||
dap: str | None = None
|
||||
dap: str | list[str] | None = None
|
||||
dap_oversample: int = 1
|
||||
dap_parameters: dict | list | None = None
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import lmfit
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
||||
from bec_lib.scan_data_container import ScanDataContainer
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
@@ -41,6 +41,18 @@ from bec_widgets.widgets.services.scan_history_browser.scan_history_browser impo
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
_DAP_PARAM = object()
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
import lmfit # type: ignore
|
||||
else:
|
||||
try:
|
||||
import lmfit # type: ignore
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(
|
||||
f"lmfit could not be imported: {e}. Custom DAP functionality will be unavailable."
|
||||
)
|
||||
lmfit = None
|
||||
|
||||
|
||||
# noinspection PyDataclass
|
||||
@@ -696,7 +708,8 @@ class Waveform(PlotBase):
|
||||
signal_y: str | None = None,
|
||||
color: str | None = None,
|
||||
label: str | None = None,
|
||||
dap: str | None = None,
|
||||
dap: str | list[str] | None = None,
|
||||
dap_parameters: dict | list | lmfit.Parameters | None | object = None,
|
||||
scan_id: str | None = None,
|
||||
scan_number: int | None = None,
|
||||
**kwargs,
|
||||
@@ -718,9 +731,14 @@ class Waveform(PlotBase):
|
||||
signal_y(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
dap(str | list[str]): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
the same string as the LMFit model name, or a list of model names to build a composite.
|
||||
dap_parameters(dict | list | lmfit.Parameters | None): Optional lmfit parameter overrides sent to
|
||||
the DAP server. For a single model: values can be numeric (interpreted as fixed parameters)
|
||||
or dicts like `{"value": 1.0, "vary": False}`. For composite models (dap is list), use either
|
||||
a list aligned to the model list (each item is a param dict), or a dict of
|
||||
`{ "ModelName": { "param": {...} } }` when model names are unique.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -733,6 +751,8 @@ class Waveform(PlotBase):
|
||||
source = "custom"
|
||||
x_data = None
|
||||
y_data = None
|
||||
if dap_parameters is _DAP_PARAM:
|
||||
dap_parameters = kwargs.pop("dap_parameters", None) or kwargs.pop("parameters", None)
|
||||
|
||||
# 1. Custom curve logic
|
||||
if x is not None and y is not None:
|
||||
@@ -810,7 +830,9 @@ class Waveform(PlotBase):
|
||||
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
|
||||
|
||||
if dap is not None and curve.config.source in ("device", "history", "custom"):
|
||||
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
|
||||
self.add_dap_curve(
|
||||
device_label=curve.name(), dap_name=dap, dap_parameters=dap_parameters, **kwargs
|
||||
)
|
||||
|
||||
return curve
|
||||
|
||||
@@ -820,9 +842,10 @@ class Waveform(PlotBase):
|
||||
def add_dap_curve(
|
||||
self,
|
||||
device_label: str,
|
||||
dap_name: str,
|
||||
dap_name: str | list[str],
|
||||
color: str | None = None,
|
||||
dap_oversample: int = 1,
|
||||
dap_parameters: dict | list | lmfit.Parameters | None = None,
|
||||
**kwargs,
|
||||
) -> Curve:
|
||||
"""
|
||||
@@ -832,9 +855,11 @@ class Waveform(PlotBase):
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
dap_name(str | list[str]): The name of the DAP model to use, or a list of model
|
||||
names to build a composite model.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
dap_parameters(dict | list | lmfit.Parameters | None): Optional lmfit parameter overrides sent to the DAP server.
|
||||
**kwargs
|
||||
|
||||
Returns:
|
||||
@@ -859,7 +884,7 @@ class Waveform(PlotBase):
|
||||
dev_entry = "custom"
|
||||
|
||||
# 2) Build a label for the new DAP curve
|
||||
dap_label = f"{device_label}-{dap_name}"
|
||||
dap_label = f"{device_label}-{self._format_dap_label(dap_name)}"
|
||||
|
||||
# 3) Possibly raise if the DAP curve already exists
|
||||
if self._check_curve_id(dap_label):
|
||||
@@ -882,7 +907,11 @@ class Waveform(PlotBase):
|
||||
|
||||
# Attach device signal with DAP
|
||||
config.signal = DeviceSignal(
|
||||
device=dev_name, signal=dev_entry, dap=dap_name, dap_oversample=dap_oversample
|
||||
device=dev_name,
|
||||
signal=dev_entry,
|
||||
dap=dap_name,
|
||||
dap_oversample=dap_oversample,
|
||||
dap_parameters=self._normalize_dap_parameters(dap_parameters, dap_name=dap_name),
|
||||
)
|
||||
|
||||
# 4) Create the DAP curve config using `_add_curve(...)`
|
||||
@@ -1754,7 +1783,9 @@ class Waveform(PlotBase):
|
||||
|
||||
x_data, y_data = parent_curve.get_data()
|
||||
model_name = dap_curve.config.signal.dap
|
||||
model = getattr(self.dap, model_name)
|
||||
model = None
|
||||
if not isinstance(model_name, (list, tuple)):
|
||||
model = getattr(self.dap, model_name)
|
||||
try:
|
||||
x_min, x_max = self.roi_region
|
||||
x_data, y_data = self._crop_data(x_data, y_data, x_min, x_max)
|
||||
@@ -1762,20 +1793,132 @@ class Waveform(PlotBase):
|
||||
x_min = None
|
||||
x_max = None
|
||||
|
||||
dap_parameters = getattr(dap_curve.config.signal, "dap_parameters", None)
|
||||
dap_kwargs = {
|
||||
"data_x": x_data,
|
||||
"data_y": y_data,
|
||||
"oversample": dap_curve.dap_oversample,
|
||||
}
|
||||
if dap_parameters:
|
||||
dap_kwargs["parameters"] = dap_parameters
|
||||
|
||||
if model is not None:
|
||||
class_args = model._plugin_info["class_args"]
|
||||
class_kwargs = model._plugin_info["class_kwargs"]
|
||||
else:
|
||||
class_args = []
|
||||
class_kwargs = {"model": model_name}
|
||||
|
||||
msg = messages.DAPRequestMessage(
|
||||
dap_cls="LmfitService1D",
|
||||
dap_type="on_demand",
|
||||
config={
|
||||
"args": [],
|
||||
"kwargs": {"data_x": x_data, "data_y": y_data},
|
||||
"class_args": model._plugin_info["class_args"],
|
||||
"class_kwargs": model._plugin_info["class_kwargs"],
|
||||
"kwargs": dap_kwargs,
|
||||
"class_args": class_args,
|
||||
"class_kwargs": class_kwargs,
|
||||
"curve_label": dap_curve.name(),
|
||||
},
|
||||
metadata={"RID": f"{self.scan_id}-{self.gui_id}"},
|
||||
)
|
||||
self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_dap_parameters(
|
||||
parameters: dict | list | lmfit.Parameters | None, dap_name: str | list[str] | None = None
|
||||
) -> dict | list | None:
|
||||
"""
|
||||
Normalize user-provided lmfit parameters into a JSON-serializable dict suitable for the DAP server.
|
||||
|
||||
Supports:
|
||||
- `lmfit.Parameters` (single-model only)
|
||||
- `dict[name -> number]` (treated as fixed parameter with `vary=False`)
|
||||
- `dict[name -> dict]` (lmfit.Parameter fields; defaults to `vary=False` if unspecified)
|
||||
- `dict[name -> lmfit.Parameter]`
|
||||
- composite: `list[dict[param_name -> spec]]` aligned to model list
|
||||
- composite: `dict[model_name -> dict[param_name -> spec]]` (unique model names only)
|
||||
"""
|
||||
if parameters is None:
|
||||
return None
|
||||
if isinstance(dap_name, (list, tuple)):
|
||||
if lmfit is not None and isinstance(parameters, lmfit.Parameters):
|
||||
raise TypeError("dap_parameters must be a dict when using composite dap models.")
|
||||
if isinstance(parameters, (list, tuple)):
|
||||
normalized_list: list[dict | None] = []
|
||||
for idx, item in enumerate(parameters):
|
||||
if item is None:
|
||||
normalized_list.append(None)
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
raise TypeError(
|
||||
f"dap_parameters list item {idx} must be a dict of parameter overrides."
|
||||
)
|
||||
normalized_list.append(Waveform._normalize_param_overrides(item))
|
||||
return normalized_list or None
|
||||
if not isinstance(parameters, dict):
|
||||
raise TypeError(
|
||||
"dap_parameters must be a dict of model->params when using composite dap models."
|
||||
)
|
||||
model_names = set(dap_name)
|
||||
invalid_models = set(parameters.keys()) - model_names
|
||||
if invalid_models:
|
||||
raise TypeError(
|
||||
f"Invalid dap_parameters keys for composite model: {sorted(invalid_models)}"
|
||||
)
|
||||
normalized_composite: dict[str, dict] = {}
|
||||
for model_name in dap_name:
|
||||
model_params = parameters.get(model_name)
|
||||
if model_params is None:
|
||||
continue
|
||||
if not isinstance(model_params, dict):
|
||||
raise TypeError(
|
||||
f"dap_parameters for '{model_name}' must be a dict of parameter overrides."
|
||||
)
|
||||
normalized = Waveform._normalize_param_overrides(model_params)
|
||||
if normalized:
|
||||
normalized_composite[model_name] = normalized
|
||||
return normalized_composite or None
|
||||
|
||||
if lmfit is not None and isinstance(parameters, lmfit.Parameters):
|
||||
return serialize_lmfit_params(parameters)
|
||||
if not isinstance(parameters, dict):
|
||||
if lmfit is None:
|
||||
raise TypeError(
|
||||
"dap_parameters must be a dict when lmfit is not installed on the client."
|
||||
)
|
||||
raise TypeError("dap_parameters must be a dict or lmfit.Parameters (or omitted).")
|
||||
|
||||
return Waveform._normalize_param_overrides(parameters)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_param_overrides(parameters: dict) -> dict | None:
|
||||
normalized: dict[str, dict] = {}
|
||||
for name, spec in parameters.items():
|
||||
if spec is None:
|
||||
continue
|
||||
if isinstance(spec, (int, float, np.number)):
|
||||
normalized[name] = {"name": name, "value": float(spec), "vary": False}
|
||||
continue
|
||||
if lmfit is not None and isinstance(spec, lmfit.Parameter):
|
||||
normalized[name] = serialize_param_object(spec)
|
||||
continue
|
||||
if isinstance(spec, dict):
|
||||
normalized[name] = {"name": name, **spec}
|
||||
if "vary" not in normalized[name]:
|
||||
normalized[name]["vary"] = False
|
||||
continue
|
||||
raise TypeError(
|
||||
f"Invalid dap_parameters entry for '{name}': expected number, dict, or lmfit.Parameter."
|
||||
)
|
||||
|
||||
return normalized or None
|
||||
|
||||
@staticmethod
|
||||
def _format_dap_label(dap_name: str | list[str]) -> str:
|
||||
if isinstance(dap_name, (list, tuple)):
|
||||
return "+".join(dap_name)
|
||||
return dap_name
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_dap_curves(self, msg, metadata):
|
||||
"""
|
||||
@@ -1793,14 +1936,6 @@ class Waveform(PlotBase):
|
||||
if not curve:
|
||||
return
|
||||
|
||||
# Get data from the parent (device) curve
|
||||
parent_curve = self._find_curve_by_label(curve.config.parent_label)
|
||||
if parent_curve is None:
|
||||
return
|
||||
x_parent, _ = parent_curve.get_data()
|
||||
if x_parent is None or len(x_parent) == 0:
|
||||
return
|
||||
|
||||
# Retrieve and store the fit parameters and summary from the DAP server response
|
||||
try:
|
||||
curve.dap_params = msg["data"][1]["fit_parameters"]
|
||||
@@ -1809,19 +1944,13 @@ class Waveform(PlotBase):
|
||||
logger.warning(f"Failed to retrieve DAP data for curve '{curve.name()}'")
|
||||
return
|
||||
|
||||
# Render model according to the DAP model name and parameters
|
||||
model_name = curve.config.signal.dap
|
||||
model_function = getattr(lmfit.models, model_name)()
|
||||
|
||||
x_min, x_max = x_parent.min(), x_parent.max()
|
||||
oversample = curve.dap_oversample
|
||||
new_x = np.linspace(x_min, x_max, int(len(x_parent) * oversample))
|
||||
|
||||
# Evaluate the model with the provided parameters to generate the y values
|
||||
new_y = model_function.eval(**curve.dap_params, x=new_x)
|
||||
|
||||
# Update the curve with the new data
|
||||
curve.setData(new_x, new_y)
|
||||
# Plot the fitted curve using the server-provided output to avoid requiring lmfit on the client.
|
||||
try:
|
||||
fit_data = msg["data"][0]
|
||||
curve.setData(np.asarray(fit_data["x"]), np.asarray(fit_data["y"]))
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to plot DAP result for curve '{curve.name()}', error: {e}")
|
||||
return
|
||||
|
||||
metadata.update({"curve_id": curve_id})
|
||||
self.dap_params_update.emit(curve.dap_params, metadata)
|
||||
@@ -2341,24 +2470,20 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Waveform Demo")
|
||||
self.resize(1200, 600)
|
||||
self.resize(1600, 600)
|
||||
self.main_widget = QWidget(self)
|
||||
self.layout = QHBoxLayout(self.main_widget)
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.waveform_popup = Waveform(popups=True)
|
||||
self.waveform_popup.plot(device_y="waveform")
|
||||
|
||||
self.waveform_side = Waveform(popups=False)
|
||||
self.waveform_side.plot(device_y="bpm4i", signal_y="bpm4i", dap="GaussianModel")
|
||||
self.waveform_side.plot(device_y="bpm3a", signal_y="bpm3a")
|
||||
|
||||
self.custom_waveform = Waveform(popups=True)
|
||||
self._populate_custom_curve_demo()
|
||||
|
||||
self.layout.addWidget(self.waveform_side)
|
||||
self.layout.addWidget(self.waveform_popup)
|
||||
self.sine_waveform = Waveform(popups=True)
|
||||
self.sine_waveform.dap_params_update.connect(self._log_sine_dap_params)
|
||||
self._populate_sine_curve_demo()
|
||||
|
||||
self.layout.addWidget(self.custom_waveform)
|
||||
self.layout.addWidget(self.sine_waveform)
|
||||
|
||||
def _populate_custom_curve_demo(self):
|
||||
"""
|
||||
@@ -2377,8 +2502,141 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
sigma = 0.8
|
||||
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise
|
||||
|
||||
# 1) No explicit parameters: server will use lmfit defaults/guesses.
|
||||
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
|
||||
|
||||
# 2) Easy dict: numbers mean "fix this parameter to value" (vary=False).
|
||||
self.custom_waveform.plot(
|
||||
x=x,
|
||||
y=y,
|
||||
label="custom-gaussian-fixed-easy",
|
||||
dap="GaussianModel",
|
||||
dap_parameters={"amplitude": 1.0},
|
||||
dap_oversample=5,
|
||||
)
|
||||
|
||||
# 3) Partial parameter override: this should still trigger guessing on the server
|
||||
# because not all Gaussian parameters are explicitly specified.
|
||||
self.custom_waveform.plot(
|
||||
x=x,
|
||||
y=y,
|
||||
label="custom-gaussian-partial-guess",
|
||||
dap="GaussianModel",
|
||||
dap_parameters={
|
||||
"center": {"value": 1.2, "vary": True},
|
||||
"sigma": {"value": sigma, "vary": False, "min": 0.0},
|
||||
},
|
||||
)
|
||||
|
||||
# 4) Complete parameter override: this should skip guessing on the server.
|
||||
if lmfit is not None:
|
||||
params_gauss = lmfit.models.GaussianModel().make_params()
|
||||
params_gauss["amplitude"].set(value=amplitude, vary=False)
|
||||
params_gauss["center"].set(value=center, vary=False)
|
||||
params_gauss["sigma"].set(value=sigma, vary=False, min=0.0)
|
||||
self.custom_waveform.plot(
|
||||
x=x,
|
||||
y=y,
|
||||
label="custom-gaussian-complete-no-guess",
|
||||
dap="GaussianModel",
|
||||
dap_parameters=params_gauss,
|
||||
)
|
||||
else:
|
||||
logger.info("Skipping lmfit.Parameters demo (lmfit not installed on client).")
|
||||
|
||||
# Composite example: spectrum with three Gaussians (DAP-only)
|
||||
x_spec = np.linspace(-5, 5, 800)
|
||||
rng_spec = np.random.default_rng(123)
|
||||
centers = [-2.0, 0.6, 2.4]
|
||||
amplitudes = [2.5, 3.2, 1.8]
|
||||
sigmas = [0.35, 0.5, 0.3]
|
||||
y_spec = (
|
||||
amplitudes[0] * np.exp(-((x_spec - centers[0]) ** 2) / (2 * sigmas[0] ** 2))
|
||||
+ amplitudes[1] * np.exp(-((x_spec - centers[1]) ** 2) / (2 * sigmas[1] ** 2))
|
||||
+ amplitudes[2] * np.exp(-((x_spec - centers[2]) ** 2) / (2 * sigmas[2] ** 2))
|
||||
+ rng_spec.normal(loc=0, scale=0.06, size=x_spec.size)
|
||||
)
|
||||
|
||||
# 5) Composite model with partial overrides only: this should still trigger guessing.
|
||||
self.custom_waveform.plot(
|
||||
x=x_spec,
|
||||
y=y_spec,
|
||||
label="custom-gaussian-spectrum-partial-guess",
|
||||
dap=["GaussianModel", "GaussianModel", "GaussianModel"],
|
||||
dap_parameters=[
|
||||
{"center": {"value": centers[0], "vary": False}},
|
||||
{"center": {"value": centers[1], "vary": False}},
|
||||
{"center": {"value": centers[2], "vary": False}},
|
||||
],
|
||||
)
|
||||
|
||||
# 6) Composite model with all component parameters specified: this should skip guessing.
|
||||
self.custom_waveform.plot(
|
||||
x=x_spec,
|
||||
y=y_spec,
|
||||
label="custom-gaussian-spectrum-complete-no-guess",
|
||||
dap=["GaussianModel", "GaussianModel", "GaussianModel"],
|
||||
dap_parameters=[
|
||||
{
|
||||
"amplitude": {"value": amplitudes[0], "vary": False},
|
||||
"center": {"value": centers[0], "vary": False},
|
||||
"sigma": {"value": sigmas[0], "vary": False, "min": 0.0},
|
||||
},
|
||||
{
|
||||
"amplitude": {"value": amplitudes[1], "vary": False},
|
||||
"center": {"value": centers[1], "vary": False},
|
||||
"sigma": {"value": sigmas[1], "vary": False, "min": 0.0},
|
||||
},
|
||||
{
|
||||
"amplitude": {"value": amplitudes[2], "vary": False},
|
||||
"center": {"value": centers[2], "vary": False},
|
||||
"sigma": {"value": sigmas[2], "vary": False, "min": 0.0},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def _populate_sine_curve_demo(self):
|
||||
"""
|
||||
Showcase how lmfit's base SineModel can struggle with a drifting baseline.
|
||||
"""
|
||||
x = np.linspace(0, 6 * np.pi, 600)
|
||||
rng = np.random.default_rng(7)
|
||||
amplitude = 1.6
|
||||
frequency = 0.75
|
||||
phase = 0.4
|
||||
offset = 0.8
|
||||
slope = 0.08
|
||||
noise = rng.normal(loc=0, scale=0.12, size=x.size)
|
||||
y = offset + slope * x + amplitude * np.sin(2 * np.pi * frequency * x + phase) + noise
|
||||
|
||||
# Base SineModel (no offset support) to show the mismatch
|
||||
self.sine_waveform.plot(x=x, y=y, label="custom-sine-data", dap="SineModel")
|
||||
|
||||
# Composite model: Sine + Linear baseline (offset + slope)
|
||||
self.sine_waveform.plot(
|
||||
x=x,
|
||||
y=y,
|
||||
label="custom-sine-composite",
|
||||
dap=["SineModel", "LinearModel"],
|
||||
dap_oversample=4,
|
||||
)
|
||||
|
||||
if lmfit is None:
|
||||
logger.info("Skipping sine lmfit demo (lmfit not installed on client).")
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _log_sine_dap_params(params: dict, metadata: dict):
|
||||
curve_id = metadata.get("curve_id")
|
||||
if curve_id not in {
|
||||
"custom-sine-data-SineModel",
|
||||
"custom-sine-composite-SineModel+LinearModel",
|
||||
}:
|
||||
return
|
||||
logger.info(f"SineModel DAP fit params ({curve_id}): {params}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.1.1"
|
||||
version = "3.2.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -13,9 +13,9 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client~=3.106", # needed for jupyter console
|
||||
"bec_lib~=3.106",
|
||||
"bec_qthemes~=1.0, >=1.3.3",
|
||||
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
||||
"bec_lib~=3.107,>=3.107.2",
|
||||
"bec_qthemes~=1.0, >=1.3.4",
|
||||
"black>=26,<27", # needed for bw-generate-cli
|
||||
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
||||
"ophyd_devices~=1.29, >=1.29.1",
|
||||
|
||||
@@ -150,7 +150,9 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
# communication should work, main dock area should have same id and be visible
|
||||
|
||||
yw = gui.new("Y")
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
|
||||
yw.delete_all()
|
||||
assert len(gui.windows) == 2
|
||||
yw.remove()
|
||||
assert len(gui.windows) == 1 # only bec is left
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||
assert len(gui.windows) == 1
|
||||
|
||||
@@ -72,6 +72,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj):
|
||||
"dap": None,
|
||||
"device": "bpm4i",
|
||||
"signal": "bpm4i",
|
||||
"dap_parameters": None,
|
||||
"dap_oversample": 1,
|
||||
}
|
||||
assert c1._config_dict["source"] == "device"
|
||||
|
||||
@@ -135,144 +135,160 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
|
||||
wait_for_namespace_change(
|
||||
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
|
||||
)
|
||||
qtbot.waitUntil(lambda: hasattr(gui, "dock_area") is False, timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECProgressBar widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
|
||||
widget: client.BECProgressBar
|
||||
|
||||
# Check rpc calls
|
||||
assert widget.label_template == "$value / $maximum - $percentage %"
|
||||
widget.set_maximum(100)
|
||||
widget.set_minimum(50)
|
||||
widget.set_value(75)
|
||||
|
||||
assert widget._get_label() == "75 / 100 - 50 %"
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECQueue widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
|
||||
widget: client.BECQueue
|
||||
|
||||
# No rpc calls to test so far
|
||||
# maybe we can add an rpc call to check the queue length
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the BECStatusBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
|
||||
|
||||
# Check rpc calls
|
||||
assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DAPComboBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
|
||||
widget: client.DAPComboBox
|
||||
|
||||
# Check rpc calls
|
||||
widget.select_fit_model("PseudoVoigtModel")
|
||||
widget.select_x_axis("samx")
|
||||
widget.select_y_axis("bpm4i")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the DeviceBrowser widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
|
||||
widget: client.DeviceBrowser
|
||||
|
||||
# No rpc calls yet to check
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the Image widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.Image)
|
||||
widget: client.Image
|
||||
|
||||
scans = bec.scans
|
||||
dev = bec.device_manager.devices
|
||||
# Test rpc calls
|
||||
img = widget.image(device=dev.eiger.name, signal="preview")
|
||||
assert img.get_data() is None
|
||||
# Run a scan and plot the image
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# Check that last image is equivalent to data in Redis
|
||||
last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
|
||||
"data"
|
||||
].data
|
||||
assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# Now add a device with a preview signal
|
||||
img = widget.image(device="eiger", signal="preview")
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# TODO re-enable when issue is resolved #560
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the LogPanel widget."""
|
||||
# def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the BECProgressBar widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
||||
# widget: client.LogPanel
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
|
||||
# widget: client.BECProgressBar
|
||||
|
||||
# # Check rpc calls
|
||||
# assert widget.label_template == "$value / $maximum - $percentage %"
|
||||
# widget.set_maximum(100)
|
||||
# widget.set_minimum(50)
|
||||
# widget.set_value(75)
|
||||
|
||||
# assert widget._get_label() == "75 / 100 - 50 %"
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the BECQueue widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
|
||||
# widget: client.BECQueue
|
||||
|
||||
# # No rpc calls to test so far
|
||||
# # maybe we can add an rpc call to check the queue length
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the BECStatusBox widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
|
||||
|
||||
# # Check rpc calls
|
||||
# assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the DAPComboBox widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
|
||||
# widget: client.DAPComboBox
|
||||
|
||||
# # Check rpc calls
|
||||
# widget.select_fit_model("PseudoVoigtModel")
|
||||
# widget.select_x_axis("samx")
|
||||
# widget.select_y_axis("bpm4i")
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the DeviceBrowser widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
|
||||
# widget: client.DeviceBrowser
|
||||
|
||||
# # No rpc calls yet to check
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the Image widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.Image)
|
||||
# widget: client.Image
|
||||
|
||||
# scans = bec.scans
|
||||
# dev = bec.device_manager.devices
|
||||
# # Test rpc calls
|
||||
# img = widget.image(device=dev.eiger.name, signal="preview")
|
||||
# assert img.get_data() is None
|
||||
# # Run a scan and plot the image
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
# def _wait_for_scan_in_history():
|
||||
# # Get scan item from history
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# return scan_item is not None
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# # Check that last image is equivalent to data in Redis
|
||||
# last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
|
||||
# "data"
|
||||
# ].data
|
||||
# assert np.allclose(img.get_data(), last_img)
|
||||
|
||||
# # Now add a device with a preview signal
|
||||
# img = widget.image(device="eiger", signal="preview")
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# # TODO re-enable when issue is resolved #560
|
||||
# # @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# # def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# # """Test the LogPanel widget."""
|
||||
# # gui = connected_client_gui_obj
|
||||
# # bec = gui._client
|
||||
# # # Create dock_area and widget
|
||||
# # widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
||||
# # widget: client.LogPanel
|
||||
|
||||
# # # No rpc calls to check so far
|
||||
|
||||
# # # Test removing the widget, or leaving it open for the next test
|
||||
# # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the MineSweeper widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
|
||||
# widget: client.MineSweeper
|
||||
|
||||
# # No rpc calls to check so far
|
||||
|
||||
@@ -280,175 +296,160 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MineSweeper widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
|
||||
widget: client.MineSweeper
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the MotorMap widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
|
||||
# widget: client.MotorMap
|
||||
|
||||
# No rpc calls to check so far
|
||||
# # Test RPC calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # Set motor map to names
|
||||
# widget.map(dev.samx, dev.samy)
|
||||
# # Move motor samx to pos
|
||||
# pos = dev.samx.limits[1] - 1 # -1 from higher limit
|
||||
# scans.mv(dev.samx, pos, relative=False).wait()
|
||||
# # Check that data is up to date
|
||||
# assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
|
||||
# # Move motor samy to pos
|
||||
# pos = dev.samy.limits[0] + 1 # +1 from lower limit
|
||||
# scans.mv(dev.samy, pos, relative=False).wait()
|
||||
# # Check that data is up to date
|
||||
# assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the MotorMap widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
|
||||
widget: client.MotorMap
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test MultiWaveform widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
|
||||
# widget: client.MultiWaveform
|
||||
|
||||
# Test RPC calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Set motor map to names
|
||||
widget.map(dev.samx, dev.samy)
|
||||
# Move motor samx to pos
|
||||
pos = dev.samx.limits[1] - 1 # -1 from higher limit
|
||||
scans.mv(dev.samx, pos, relative=False).wait()
|
||||
# Check that data is up to date
|
||||
assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
|
||||
# Move motor samy to pos
|
||||
pos = dev.samy.limits[0] + 1 # +1 from lower limit
|
||||
scans.mv(dev.samy, pos, relative=False).wait()
|
||||
# Check that data is up to date
|
||||
assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
|
||||
# # Test RPC calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # test plotting
|
||||
# cm = "cividis"
|
||||
# widget.plot(dev.waveform, color_palette=cm)
|
||||
# assert widget.monitor == dev.waveform.name
|
||||
# assert widget.color_palette == cm
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Scan with BEC
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
# def _wait_for_scan_in_history():
|
||||
# # Get scan item from history
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# return scan_item is not None
|
||||
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
# # Wait for data in history (should be plotted?)
|
||||
|
||||
# # TODO how can we check that the data was plotted, implement get_data()
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test MultiWaveform widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
|
||||
widget: client.MultiWaveform
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_indicator(
|
||||
# qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
# ):
|
||||
# """Test the PositionIndicator widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
|
||||
# widget: client.PositionIndicator
|
||||
|
||||
# Test RPC calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# test plotting
|
||||
cm = "cividis"
|
||||
widget.plot(dev.waveform, color_palette=cm)
|
||||
assert widget.monitor == dev.waveform.name
|
||||
assert widget.color_palette == cm
|
||||
# # TODO check what these rpc calls are supposed to do! Issue created #461
|
||||
# widget.set_value(5)
|
||||
|
||||
# Scan with BEC
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
# Wait for data in history (should be plotted?)
|
||||
|
||||
# TODO how can we check that the data was plotted, implement get_data()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_indicator(
|
||||
qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
):
|
||||
"""Test the PositionIndicator widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
|
||||
widget: client.PositionIndicator
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the PositionerBox widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
|
||||
# widget: client.PositionerBox
|
||||
|
||||
# TODO check what these rpc calls are supposed to do! Issue created #461
|
||||
widget.set_value(5)
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # No rpc calls to check so far
|
||||
# widget.set_positioner(dev.samx)
|
||||
# widget.set_positioner(dev.samy.name)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the PositionerBox widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
|
||||
widget: client.PositionerBox
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the PositionerBox2D widget."""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
|
||||
# widget: client.PositionerBox2D
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# No rpc calls to check so far
|
||||
widget.set_positioner(dev.samx)
|
||||
widget.set_positioner(dev.samy.name)
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # No rpc calls to check so far
|
||||
# widget.set_positioner_hor(dev.samx)
|
||||
# widget.set_positioner_ver(dev.samy)
|
||||
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
# # Try moving the motors
|
||||
# scans.mv(dev.samx, 3, relative=False).wait()
|
||||
# scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the PositionerBox2D widget."""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
|
||||
widget: client.PositionerBox2D
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_positioner_control_line(
|
||||
# qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
# ):
|
||||
# """Test the positioner control line widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
|
||||
# widget: client.PositionerControlLine
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# No rpc calls to check so far
|
||||
widget.set_positioner_hor(dev.samx)
|
||||
widget.set_positioner_ver(dev.samy)
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# # Set positioner
|
||||
# widget.set_positioner(dev.samx)
|
||||
# scans.mv(dev.samx, 3, relative=False).wait()
|
||||
# widget.set_positioner(dev.samy.name)
|
||||
# scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Try moving the motors
|
||||
scans.mv(dev.samx, 3, relative=False).wait()
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_positioner_control_line(
|
||||
qtbot, connected_client_gui_obj, random_generator_from_seed
|
||||
):
|
||||
"""Test the positioner control line widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
|
||||
widget: client.PositionerControlLine
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
# Set positioner
|
||||
widget.set_positioner(dev.samx)
|
||||
scans.mv(dev.samx, 3, relative=False).wait()
|
||||
widget.set_positioner(dev.samy.name)
|
||||
scans.mv(dev.samy, -3, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
# TODO passes locally, fails on CI for some reason... -> issue #1003
|
||||
@pytest.mark.repeat(20)
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the RingProgressBar widget"""
|
||||
@@ -477,86 +478,86 @@ def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_g
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the ScanControl widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
|
||||
widget: client.ScanControl
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the ScanControl widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
|
||||
# widget: client.ScanControl
|
||||
|
||||
# No rpc calls to check so far
|
||||
# # No rpc calls to check so far
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the ScatterWaveform widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
|
||||
widget: client.ScatterWaveform
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the ScatterWaveform widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
|
||||
# widget: client.ScatterWaveform
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
widget.plot(dev.samx, dev.samy, dev.bpm4i)
|
||||
scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# widget.plot(dev.samx, dev.samy, dev.bpm4i)
|
||||
# scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the TextBox widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
|
||||
widget: client.TextBox
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the TextBox widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
|
||||
# widget: client.TextBox
|
||||
|
||||
# RPC calls
|
||||
widget.set_plain_text("Hello World")
|
||||
widget.set_html_text("<b> Hello World HTML </b>")
|
||||
# # RPC calls
|
||||
# widget.set_plain_text("Hello World")
|
||||
# widget.set_html_text("<b> Hello World HTML </b>")
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
"""Test the Waveform widget"""
|
||||
gui = connected_client_gui_obj
|
||||
bec = gui._client
|
||||
# Create dock_area and widget
|
||||
widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
|
||||
widget: client.Waveform
|
||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
# def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||
# """Test the Waveform widget"""
|
||||
# gui = connected_client_gui_obj
|
||||
# bec = gui._client
|
||||
# # Create dock_area and widget
|
||||
# widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
|
||||
# widget: client.Waveform
|
||||
|
||||
# Test rpc calls
|
||||
dev = bec.device_manager.devices
|
||||
scans = bec.scans
|
||||
widget.plot(dev.bpm4i)
|
||||
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
s.wait()
|
||||
# # Test rpc calls
|
||||
# dev = bec.device_manager.devices
|
||||
# scans = bec.scans
|
||||
# widget.plot(dev.bpm4i)
|
||||
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
|
||||
# s.wait()
|
||||
|
||||
def _wait_for_scan_in_history():
|
||||
# Get scan item from history
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
return scan_item is not None
|
||||
# def _wait_for_scan_in_history():
|
||||
# # Get scan item from history
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# return scan_item is not None
|
||||
|
||||
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
|
||||
|
||||
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
samx_data = scan_item.devices.samx.samx.read()["value"]
|
||||
bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
|
||||
curve = widget.curves[0]
|
||||
assert np.allclose(curve.get_data()[0], samx_data)
|
||||
assert np.allclose(curve.get_data()[1], bpm4i_data)
|
||||
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
|
||||
# samx_data = scan_item.devices.samx.samx.read()["value"]
|
||||
# bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
|
||||
# curve = widget.curves[0]
|
||||
# assert np.allclose(curve.get_data()[0], samx_data)
|
||||
# assert np.allclose(curve.get_data()[1], bpm4i_data)
|
||||
|
||||
# Test removing the widget, or leaving it open for the next test
|
||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
# # Test removing the widget, or leaving it open for the next test
|
||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||
|
||||
@@ -6,10 +6,10 @@ import fakeredis
|
||||
import pytest
|
||||
from bec_lib.bec_service import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.redis_connector import RedisConnector
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
|
||||
|
||||
|
||||
def fake_redis_server(host, port, **kwargs):
|
||||
@@ -19,7 +19,7 @@ def fake_redis_server(host, port, **kwargs):
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mocked_client(bec_dispatcher):
|
||||
connector = QtRedisConnector("localhost:1", redis_cls=fake_redis_server)
|
||||
connector = RedisConnector("localhost:1", redis_cls=fake_redis_server)
|
||||
# Create a MagicMock object
|
||||
client = MagicMock() # TODO change to real BECClient
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
from time import sleep
|
||||
from typing import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import ProcedureExecutionMessage, ProcedureRequestMessage
|
||||
from bec_lib.procedures.helper import BackendProcedureHelper
|
||||
from bec_server.procedures.manager import ProcedureManager
|
||||
from bec_server.procedures.procedure_registry import register
|
||||
from bec_server.procedures.worker_base import ProcedureWorker
|
||||
|
||||
from bec_widgets.widgets.control.procedure_control.procedure_control import (
|
||||
ProcedureControl,
|
||||
QueueItem,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
class MockWorker(ProcedureWorker):
|
||||
def _kill_process(self): ...
|
||||
|
||||
def _run_task(self, item):
|
||||
sleep(0.1)
|
||||
|
||||
def _setup_execution_environment(self): ...
|
||||
|
||||
def abort(self): ...
|
||||
|
||||
def abort_execution(self, execution_id: str): ...
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def register_test_proc():
|
||||
register("test", lambda: None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proc_ctrl_w_helper(qtbot, mocked_client: MagicMock):
|
||||
proc_ctrl = ProcedureControl(client=mocked_client)
|
||||
qtbot.addWidget(proc_ctrl)
|
||||
with patch(
|
||||
"bec_server.procedures.manager.RedisConnector", lambda _: proc_ctrl.client.connector
|
||||
):
|
||||
manager = ProcedureManager(MagicMock(), MockWorker)
|
||||
yield proc_ctrl, BackendProcedureHelper(proc_ctrl.client.connector)
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def req_msg():
|
||||
return ProcedureRequestMessage(identifier="test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exec_msg():
|
||||
return lambda id: ProcedureExecutionMessage(identifier="test", queue="test", execution_id=id)
|
||||
|
||||
|
||||
def test_add_proc(qtbot, proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper]):
|
||||
proc_ctrl, helper = proc_ctrl_w_helper
|
||||
assert proc_ctrl._active_queues.childCount() == 0
|
||||
helper.request.procedure("test")
|
||||
qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() != 0, timeout=500)
|
||||
|
||||
|
||||
def test_abort(qtbot, proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper]):
|
||||
proc_ctrl, helper = proc_ctrl_w_helper
|
||||
assert proc_ctrl._active_queues.childCount() == 0
|
||||
helper.request.procedure("test")
|
||||
qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() != 0, timeout=500)
|
||||
|
||||
assert proc_ctrl._unhandled_queues.childCount() == 0
|
||||
queue: QueueItem = proc_ctrl._active_queues.child(0)
|
||||
queue.child(0).actions_widget.layout().itemAt(0).widget().click()
|
||||
qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() == 0, timeout=500)
|
||||
qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() != 0, timeout=500)
|
||||
|
||||
|
||||
def test_delete(
|
||||
qtbot,
|
||||
proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper],
|
||||
exec_msg: Callable[[str], ProcedureExecutionMessage],
|
||||
):
|
||||
proc_ctrl, helper = proc_ctrl_w_helper
|
||||
[helper.push.unhandled("test", exec_msg("abcd")) for _ in range(3)]
|
||||
qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() != 0, timeout=500)
|
||||
queue: QueueItem = proc_ctrl._unhandled_queues.child(0)
|
||||
assert queue.childCount() == 3
|
||||
queue.actions_widget.layout().itemAt(0).widget().click()
|
||||
qtbot.waitUntil(lambda: helper.get.unhandled_queue("test") == [], timeout=500)
|
||||
qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() == 0, timeout=500)
|
||||
|
||||
|
||||
def test_resubmit(
|
||||
qtbot,
|
||||
proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper],
|
||||
exec_msg: Callable[[str], ProcedureExecutionMessage],
|
||||
):
|
||||
proc_ctrl, helper = proc_ctrl_w_helper
|
||||
[helper.push.unhandled("test", exec_msg(f"abcd{i}")) for i in range(3)]
|
||||
qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() != 0, timeout=500)
|
||||
queue: QueueItem = proc_ctrl._unhandled_queues.child(0)
|
||||
assert queue.childCount() == 3
|
||||
assert proc_ctrl._active_queues.childCount() == 0
|
||||
queue.child(0).actions_widget.layout().itemAt(1).widget().click()
|
||||
qtbot.waitUntil(lambda: len(helper.get.unhandled_queue("test")) == 2, timeout=500)
|
||||
qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() != 0, timeout=500)
|
||||
@@ -516,6 +516,112 @@ def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_normalize_dap_parameters_number_dict():
|
||||
normalized = Waveform._normalize_dap_parameters({"amplitude": 1.0, "center": 2})
|
||||
assert normalized == {
|
||||
"amplitude": {"name": "amplitude", "value": 1.0, "vary": False},
|
||||
"center": {"name": "center", "value": 2.0, "vary": False},
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_dap_parameters_dict_spec_defaults_vary_false():
|
||||
normalized = Waveform._normalize_dap_parameters({"sigma": {"value": 0.8, "min": 0.0}})
|
||||
assert normalized["sigma"]["name"] == "sigma"
|
||||
assert normalized["sigma"]["value"] == 0.8
|
||||
assert normalized["sigma"]["min"] == 0.0
|
||||
assert normalized["sigma"]["vary"] is False
|
||||
|
||||
|
||||
def test_normalize_dap_parameters_invalid_type_raises():
|
||||
with pytest.raises(TypeError):
|
||||
Waveform._normalize_dap_parameters(["amplitude", 1.0]) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_normalize_dap_parameters_composite_list():
|
||||
normalized = Waveform._normalize_dap_parameters(
|
||||
[{"center": 1.0}, {"sigma": {"value": 0.5, "min": 0.0}}],
|
||||
dap_name=["GaussianModel", "GaussianModel"],
|
||||
)
|
||||
assert normalized == [
|
||||
{"center": {"name": "center", "value": 1.0, "vary": False}},
|
||||
{"sigma": {"name": "sigma", "value": 0.5, "min": 0.0, "vary": False}},
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_dap_parameters_composite_dict():
|
||||
normalized = Waveform._normalize_dap_parameters(
|
||||
{
|
||||
"GaussianModel": {"center": {"value": 1.0, "vary": True}},
|
||||
"LorentzModel": {"amplitude": 2.0},
|
||||
},
|
||||
dap_name=["GaussianModel", "LorentzModel"],
|
||||
)
|
||||
assert normalized["GaussianModel"]["center"]["value"] == 1.0
|
||||
assert normalized["GaussianModel"]["center"]["vary"] is True
|
||||
assert normalized["LorentzModel"]["amplitude"]["value"] == 2.0
|
||||
assert normalized["LorentzModel"]["amplitude"]["vary"] is False
|
||||
|
||||
|
||||
def test_request_dap_includes_normalized_parameters(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
curve = wf.plot(
|
||||
x=[0, 1, 2],
|
||||
y=[1, 2, 3],
|
||||
label="custom-inline-params",
|
||||
dap="GaussianModel",
|
||||
dap_parameters={"amplitude": 1.0},
|
||||
)
|
||||
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
||||
assert dap_curve is not None
|
||||
dap_curve.dap_oversample = 3
|
||||
|
||||
captured = {}
|
||||
|
||||
def capture(topic, msg, *args, **kwargs): # noqa: ARG001
|
||||
captured["topic"] = topic
|
||||
captured["msg"] = msg
|
||||
|
||||
monkeypatch.setattr(wf.client.connector, "set_and_publish", capture)
|
||||
wf.request_dap()
|
||||
|
||||
msg = captured["msg"]
|
||||
dap_kwargs = msg.content["config"]["kwargs"]
|
||||
assert dap_kwargs["oversample"] == 3
|
||||
assert dap_kwargs["parameters"] == {
|
||||
"amplitude": {"name": "amplitude", "value": 1.0, "vary": False}
|
||||
}
|
||||
|
||||
|
||||
def test_request_dap_includes_composite_parameters_list(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
curve = wf.plot(
|
||||
x=[0, 1, 2],
|
||||
y=[1, 2, 3],
|
||||
label="custom-composite",
|
||||
dap=["GaussianModel", "GaussianModel"],
|
||||
dap_parameters=[{"center": 0.0}, {"center": 1.0}],
|
||||
)
|
||||
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel+GaussianModel")
|
||||
assert dap_curve is not None
|
||||
|
||||
captured = {}
|
||||
|
||||
def capture(topic, msg, *args, **kwargs): # noqa: ARG001
|
||||
captured["topic"] = topic
|
||||
captured["msg"] = msg
|
||||
|
||||
monkeypatch.setattr(wf.client.connector, "set_and_publish", capture)
|
||||
wf.request_dap()
|
||||
|
||||
msg = captured["msg"]
|
||||
dap_kwargs = msg.content["config"]["kwargs"]
|
||||
assert dap_kwargs["parameters"] == [
|
||||
{"center": {"name": "center", "value": 0.0, "vary": False}},
|
||||
{"center": {"name": "center", "value": 1.0, "vary": False}},
|
||||
]
|
||||
assert msg.content["config"]["class_kwargs"]["model"] == ["GaussianModel", "GaussianModel"]
|
||||
|
||||
|
||||
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
|
||||
|
||||
Reference in New Issue
Block a user