1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-19 23:05:36 +02:00

Compare commits

...

22 Commits

Author SHA1 Message Date
a275c5a3b1 wip 2026-03-15 14:31:39 +01:00
b12e6432d6 wip 2026-03-15 13:59:43 +01:00
b802beec69 wip 2026-03-15 13:53:37 +01:00
6c40df4380 wip 2026-03-15 13:36:13 +01:00
dc8ae5a85d wip 2026-03-15 13:31:45 +01:00
2d41a158bc wip 2026-03-15 13:25:42 +01:00
c2a6964875 wip 2026-03-15 13:18:15 +01:00
774ca08cb3 wip 2026-03-15 13:06:18 +01:00
4606bbd570 wip 2026-03-15 13:01:44 +01:00
b2ed9d42f7 wip 2026-03-15 12:57:19 +01:00
d799691e12 wip 2026-03-15 12:47:58 +01:00
6848a9e20b test(e2e): avoid timing issues in rpc_gui_obj test 2026-03-15 12:30:40 +01:00
semantic-release
bd5aafc052 3.2.0
Automatically generated by python-semantic-release
2026-03-11 20:52:57 +00:00
b4f6f5aa8b feat(waveform): composite DAP with multiple models 2026-03-11 21:52:10 +01:00
14d51b8016 feat(curve, waveform): add dap_parameters for lmfit customization in DAP requests 2026-03-11 21:52:10 +01:00
semantic-release
e94554b471 3.1.4
Automatically generated by python-semantic-release
2026-03-11 11:58:34 +00:00
7e0e391888 build: increased minimal version of bec and bec qthemes 2026-03-11 12:57:40 +01:00
53e5ec42b8 fix(profile_utils): renamed to fetch widgets settings 2026-03-11 12:57:40 +01:00
semantic-release
0e49828a23 3.1.3
Automatically generated by python-semantic-release
2026-03-09 08:46:29 +00:00
278d8de058 fix(monaco_dock): optimization, removal of QTimer, eventFilter replaced by signal/slot 2026-03-09 09:45:40 +01:00
semantic-release
cb4c2beed4 3.1.2
Automatically generated by python-semantic-release
2026-03-06 15:34:15 +00:00
4382d5c9b1 fix(dock_area): remove old AdvancedDockArea references 2026-03-06 16:33:23 +01:00
18 changed files with 923 additions and 493 deletions

View File

@@ -1,19 +1,19 @@
name: Full CI name: Full CI
on: on:
push: push:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
BEC_WIDGETS_BRANCH: BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install' description: "Branch of BEC Widgets to install"
required: false required: false
type: string type: string
BEC_CORE_BRANCH: BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install' description: "Branch of BEC Core to install"
required: false required: false
type: string type: string
OPHYD_DEVICES_BRANCH: OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install' description: "Branch of Ophyd Devices to install"
required: false required: false
type: string type: string
@@ -25,60 +25,58 @@ permissions:
pull-requests: write pull-requests: write
jobs: jobs:
check_pr_status: # check_pr_status:
uses: ./.github/workflows/check_pr.yml # uses: ./.github/workflows/check_pr.yml
formatter: # formatter:
needs: check_pr_status # needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == '' # if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml # uses: ./.github/workflows/formatter.yml
unit-test: # unit-test:
needs: [check_pr_status, formatter] # needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' # if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml # uses: ./.github/workflows/pytest.yml
with: # with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }} # BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }} # BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }} # OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
secrets: # secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test-matrix: # unit-test-matrix:
needs: [check_pr_status, formatter] # needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' # if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml # uses: ./.github/workflows/pytest-matrix.yml
with: # with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}} # BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }} # BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }} # OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
generate-cli-test: # generate-cli-test:
needs: [check_pr_status, formatter] # needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' # if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml # uses: ./.github/workflows/generate-cli-check.yml
end2end-test: end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml uses: ./.github/workflows/end2end-conda.yml
child-repos: # child-repos:
needs: [check_pr_status, formatter] # needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == '' # if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/child_repos.yml # uses: ./.github/workflows/child_repos.yml
with: # with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }} # BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}} # OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }} # BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets: # plugin_repos:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }} # 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 }}

View File

@@ -48,12 +48,14 @@ jobs:
source ./bin/install_bec_dev.sh -t source ./bin/install_bec_dev.sh -t
cd ../ cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin 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 - name: Upload logs if job fails
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: pytest-logs name: pytest-logs
path: ./logs/*.log path: ./bec/logs/*.log
retention-days: 7 retention-days: 7

View File

@@ -1,6 +1,51 @@
# CHANGELOG # 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) ## v3.1.1 (2026-03-06)
### Bug Fixes ### Bug Fixes

View File

@@ -187,7 +187,7 @@ class BECDockArea(RPCBase):
or a sequence of button names to hide. or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses 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 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). central widget (useful for editor stacks or other root content).
object_name(str | None): Optional object name to assign to the created widget. 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. or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses 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 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). central widget (useful for editor stacks or other root content).
object_name(str | None): Optional object name to assign to the created widget. 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. or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses 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 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). central widget (useful for editor stacks or other root content).
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. 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. or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses 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 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). central widget (useful for editor stacks or other root content).
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
@@ -6249,7 +6249,8 @@ class Waveform(RPCBase):
signal_y: "str | None" = None, signal_y: "str | None" = None,
color: "str | None" = None, color: "str | None" = None,
label: "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_id: "str | None" = None,
scan_number: "int | None" = None, scan_number: "int | None" = None,
**kwargs, **kwargs,
@@ -6271,9 +6272,14 @@ class Waveform(RPCBase):
signal_y(str): The name of the entry for the y-axis. signal_y(str): The name of the entry for the y-axis.
color(str): The color of the curve. color(str): The color of the curve.
label(str): The label 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 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 scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets. never cleared by livescan resets.
@@ -6287,9 +6293,10 @@ class Waveform(RPCBase):
def add_dap_curve( def add_dap_curve(
self, self,
device_label: "str", device_label: "str",
dap_name: "str", dap_name: "str | list[str]",
color: "str | None" = None, color: "str | None" = None,
dap_oversample: "int" = 1, dap_oversample: "int" = 1,
dap_parameters: "dict | list | lmfit.Parameters | None" = None,
**kwargs, **kwargs,
) -> "Curve": ) -> "Curve":
""" """
@@ -6299,9 +6306,11 @@ class Waveform(RPCBase):
Args: Args:
device_label(str): The label of the source curve to add DAP to. 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. color(str): The color of the curve.
dap_oversample(int): The oversampling factor for the DAP 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 **kwargs
Returns: Returns:

View File

@@ -118,6 +118,9 @@ class RPCServer:
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}") logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)): with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try: try:
logger.info(
f"Processing RPC instruction: {msg['action']} with request_id: {request_id}. Parameters: {msg.get('parameter')}"
)
method = msg["action"] method = msg["action"]
args = msg["parameter"].get("args", []) args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {}) kwargs = msg["parameter"].get("kwargs", {})
@@ -257,6 +260,10 @@ class RPCServer:
else: else:
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas) 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 = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
result_widget.window().setWindowTitle(f"BEC - {name}") result_widget.window().setWindowTitle(f"BEC - {name}")
@@ -296,6 +303,9 @@ class RPCServer:
else: else:
res = self.serialize_object(res) res = self.serialize_object(res)
except RegistryNotReadyError: except RegistryNotReadyError:
logger.info(
f"Object not registered yet for RPC request {request_id}, retrying serialization after {retry_delay} ms"
)
try: try:
self._rpc_singleshot_repeats[request_id] += retry_delay self._rpc_singleshot_repeats[request_id] += retry_delay
QTimer.singleShot( QTimer.singleShot(

View File

@@ -1315,7 +1315,7 @@ class DockAreaWidget(BECWidget, QWidget):
or a sequence of button names to hide. or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses 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 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). central widget (useful for editor stacks or other root content).
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.

View File

@@ -136,10 +136,7 @@ class BECDockArea(DockAreaWidget):
self._profile_management_enabled = enable_profile_management self._profile_management_enabled = enable_profile_management
self._startup_profile = self._normalize_startup_profile(startup_profile) self._startup_profile = self._normalize_startup_profile(startup_profile)
super().__init__( super().__init__(
parent, parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
default_add_direction=default_add_direction,
title="Advanced Dock Area",
**kwargs,
) )
# Initialize mode property first (before toolbar setup) # Initialize mode property first (before toolbar setup)
@@ -168,7 +165,7 @@ class BECDockArea(DockAreaWidget):
# State manager # State manager
self.state_manager = WidgetStateManager( self.state_manager = WidgetStateManager(
self, serialize_from_root=True, root_id="AdvancedDockArea" self, serialize_from_root=True, root_id="BECDockArea"
) )
# Developer mode state # Developer mode state
@@ -304,7 +301,7 @@ class BECDockArea(DockAreaWidget):
or a sequence of button names to hide. or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses 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 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). central widget (useful for editor stacks or other root content).
object_name(str | None): Optional object name to assign to the created widget. object_name(str | None): Optional object name to assign to the created widget.

View File

@@ -1,5 +1,5 @@
""" """
Utilities for managing AdvancedDockArea profiles stored in INI files. Utilities for managing BECDockArea profiles stored in INI files.
Policy: Policy:
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user} - 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: 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: Returns:
str: Absolute path of the read-only module profiles directory. 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) @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. str: Absolute path to the profiles root. The directory is created if missing.
""" """
client = BECClient() 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_setting_path = (
bec_widgets_settings.get("base_path") if bec_widgets_settings else None bec_widgets_settings.get("base_path") if bec_widgets_settings else None
) )
default_path = os.path.join(bec_widgets_setting_path, "profiles") 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) os.makedirs(root, exist_ok=True)
return root return root
@@ -138,7 +138,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
""" """
base = os.path.join(_settings_profiles_root(), segment) base = os.path.join(_settings_profiles_root(), segment)
ns = slugify.slugify(namespace, separator="_") if namespace else None 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) os.makedirs(path, exist_ok=True)
return path return path

View File

@@ -330,7 +330,7 @@ class WorkSpaceManager(BECWidget, QWidget):
return return
self.target_widget.save_profile_dialog() 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. # but ensure the UI stays in sync even if the signal is delayed.
self.render_table() self.render_table()
current = getattr(self.target_widget, "_current_profile_name", None) current = getattr(self.target_widget, "_current_profile_name", None)

View File

@@ -118,7 +118,7 @@ class ProfileComboBox(QComboBox):
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: 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: Args:
components (ToolbarComponents): The components to be added to the bundle. 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): 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): def __init__(self, components: ToolbarComponents, target_widget=None):

View File

@@ -6,7 +6,7 @@ from typing import Any, cast
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_lib.macro_update_handler import has_executable_code 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 qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
@@ -36,12 +36,12 @@ class MonacoDock(DockAreaWidget):
**kwargs, **kwargs,
) )
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
self.dock_manager.installEventFilter(self)
self._last_focused_editor: CDockWidget | None = None self._last_focused_editor: CDockWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed) self.focused_editor.connect(self._on_last_focused_editor_changed)
initial_editor = self.add_editor() initial_editor = self.add_editor()
if isinstance(initial_editor, CDockWidget): if isinstance(initial_editor, CDockWidget):
self.last_focused_editor = initial_editor self.last_focused_editor = initial_editor
self._install_manager_scan_and_fix_guards()
def _create_editor_widget(self) -> MonacoWidget: def _create_editor_widget(self) -> MonacoWidget:
"""Create a configured Monaco editor widget.""" """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()}") logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
self.save_enabled.emit(widget.modified) 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.""" """Update the tab title to show modification status with a dot indicator."""
current_title = dock.windowTitle() current_title = dock.windowTitle()
@@ -98,14 +99,12 @@ class MonacoDock(DockAreaWidget):
return return
active_sig = signatures[signature.get("activeSignature", 0)] active_sig = signatures[signature.get("activeSignature", 0)]
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
# Get signature label and documentation # Get signature label and documentation
label = active_sig.get("label", "") label = active_sig.get("label", "")
doc_obj = active_sig.get("documentation", {}) doc_obj = active_sig.get("documentation", {})
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj) 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}" markdown = f"```python\n{label}\n```\n\n{documentation}"
self.signature_help.emit(markdown) self.signature_help.emit(markdown)
@@ -156,9 +155,10 @@ class MonacoDock(DockAreaWidget):
if self.last_focused_editor is dock: if self.last_focused_editor is dock:
self.last_focused_editor = None self.last_focused_editor = None
# After topology changes, make sure single-tab areas get a plus button # 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. Reset the given Monaco editor widget to its initial state.
@@ -193,23 +193,23 @@ class MonacoDock(DockAreaWidget):
# pylint: disable=protected-access # pylint: disable=protected-access
area._monaco_plus_btn = plus_btn 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 # Find all dock areas under this manager and ensure each single-tab area has a plus button
areas = self.dock_manager.findChildren(CDockAreaWidget) areas = self.dock_manager.findChildren(CDockAreaWidget)
for a in areas: for a in areas:
self._ensure_area_plus(a) 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( def add_editor(
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
) -> CDockWidget: ) -> CDockWidget:
@@ -258,7 +258,7 @@ class MonacoDock(DockAreaWidget):
if area_widget is not None: if area_widget is not None:
self._ensure_area_plus(area_widget) self._ensure_area_plus(area_widget)
QTimer.singleShot(0, self._scan_and_fix_areas) self._scan_and_fix_areas()
self.last_focused_editor = dock self.last_focused_editor = dock
return dock return dock

View File

@@ -22,8 +22,9 @@ class DeviceSignal(BaseModel):
device: str device: str
signal: str signal: str
dap: str | None = None dap: str | list[str] | None = None
dap_oversample: int = 1 dap_oversample: int = 1
dap_parameters: dict | list | None = None
model_config: dict = {"validate_assignment": True} model_config: dict = {"validate_assignment": True}

View File

@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Literal from typing import TYPE_CHECKING, Literal
import lmfit
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from bec_lib import bec_logger, messages from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints 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 bec_lib.scan_data_container import ScanDataContainer
from pydantic import Field, ValidationError, field_validator from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal 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 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 # noinspection PyDataclass
@@ -696,7 +708,8 @@ class Waveform(PlotBase):
signal_y: str | None = None, signal_y: str | None = None,
color: str | None = None, color: str | None = None,
label: 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_id: str | None = None,
scan_number: int | None = None, scan_number: int | None = None,
**kwargs, **kwargs,
@@ -718,9 +731,14 @@ class Waveform(PlotBase):
signal_y(str): The name of the entry for the y-axis. signal_y(str): The name of the entry for the y-axis.
color(str): The color of the curve. color(str): The color of the curve.
label(str): The label 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 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 scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
the ydata (and optional xdata) are fetched from that historical scan. Such curves are the ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan resets. never cleared by livescan resets.
@@ -733,6 +751,8 @@ class Waveform(PlotBase):
source = "custom" source = "custom"
x_data = None x_data = None
y_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 # 1. Custom curve logic
if x is not None and y is not None: 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) 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"): 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 return curve
@@ -820,9 +842,10 @@ class Waveform(PlotBase):
def add_dap_curve( def add_dap_curve(
self, self,
device_label: str, device_label: str,
dap_name: str, dap_name: str | list[str],
color: str | None = None, color: str | None = None,
dap_oversample: int = 1, dap_oversample: int = 1,
dap_parameters: dict | list | lmfit.Parameters | None = None,
**kwargs, **kwargs,
) -> Curve: ) -> Curve:
""" """
@@ -832,9 +855,11 @@ class Waveform(PlotBase):
Args: Args:
device_label(str): The label of the source curve to add DAP to. 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. color(str): The color of the curve.
dap_oversample(int): The oversampling factor for the DAP 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 **kwargs
Returns: Returns:
@@ -859,7 +884,7 @@ class Waveform(PlotBase):
dev_entry = "custom" dev_entry = "custom"
# 2) Build a label for the new DAP curve # 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 # 3) Possibly raise if the DAP curve already exists
if self._check_curve_id(dap_label): if self._check_curve_id(dap_label):
@@ -882,7 +907,11 @@ class Waveform(PlotBase):
# Attach device signal with DAP # Attach device signal with DAP
config.signal = DeviceSignal( 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(...)` # 4) Create the DAP curve config using `_add_curve(...)`
@@ -1754,7 +1783,9 @@ class Waveform(PlotBase):
x_data, y_data = parent_curve.get_data() x_data, y_data = parent_curve.get_data()
model_name = dap_curve.config.signal.dap 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: try:
x_min, x_max = self.roi_region x_min, x_max = self.roi_region
x_data, y_data = self._crop_data(x_data, y_data, x_min, x_max) 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_min = None
x_max = 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( msg = messages.DAPRequestMessage(
dap_cls="LmfitService1D", dap_cls="LmfitService1D",
dap_type="on_demand", dap_type="on_demand",
config={ config={
"args": [], "args": [],
"kwargs": {"data_x": x_data, "data_y": y_data}, "kwargs": dap_kwargs,
"class_args": model._plugin_info["class_args"], "class_args": class_args,
"class_kwargs": model._plugin_info["class_kwargs"], "class_kwargs": class_kwargs,
"curve_label": dap_curve.name(), "curve_label": dap_curve.name(),
}, },
metadata={"RID": f"{self.scan_id}-{self.gui_id}"}, metadata={"RID": f"{self.scan_id}-{self.gui_id}"},
) )
self.client.connector.set_and_publish(MessageEndpoints.dap_request(), msg) 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) @SafeSlot(dict, dict)
def update_dap_curves(self, msg, metadata): def update_dap_curves(self, msg, metadata):
""" """
@@ -1793,14 +1936,6 @@ class Waveform(PlotBase):
if not curve: if not curve:
return 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 # Retrieve and store the fit parameters and summary from the DAP server response
try: try:
curve.dap_params = msg["data"][1]["fit_parameters"] 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()}'") logger.warning(f"Failed to retrieve DAP data for curve '{curve.name()}'")
return return
# Render model according to the DAP model name and parameters # Plot the fitted curve using the server-provided output to avoid requiring lmfit on the client.
model_name = curve.config.signal.dap try:
model_function = getattr(lmfit.models, model_name)() fit_data = msg["data"][0]
curve.setData(np.asarray(fit_data["x"]), np.asarray(fit_data["y"]))
x_min, x_max = x_parent.min(), x_parent.max() except Exception as e:
oversample = curve.dap_oversample logger.exception(f"Failed to plot DAP result for curve '{curve.name()}', error: {e}")
new_x = np.linspace(x_min, x_max, int(len(x_parent) * oversample)) return
# 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)
metadata.update({"curve_id": curve_id}) metadata.update({"curve_id": curve_id})
self.dap_params_update.emit(curve.dap_params, metadata) self.dap_params_update.emit(curve.dap_params, metadata)
@@ -2341,24 +2470,20 @@ class DemoApp(QMainWindow): # pragma: no cover
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Waveform Demo") self.setWindowTitle("Waveform Demo")
self.resize(1200, 600) self.resize(1600, 600)
self.main_widget = QWidget(self) self.main_widget = QWidget(self)
self.layout = QHBoxLayout(self.main_widget) self.layout = QHBoxLayout(self.main_widget)
self.setCentralWidget(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.custom_waveform = Waveform(popups=True)
self._populate_custom_curve_demo() self._populate_custom_curve_demo()
self.layout.addWidget(self.waveform_side) self.sine_waveform = Waveform(popups=True)
self.layout.addWidget(self.waveform_popup) 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.custom_waveform)
self.layout.addWidget(self.sine_waveform)
def _populate_custom_curve_demo(self): def _populate_custom_curve_demo(self):
""" """
@@ -2377,8 +2502,141 @@ class DemoApp(QMainWindow): # pragma: no cover
sigma = 0.8 sigma = 0.8
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise 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") 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 if __name__ == "__main__": # pragma: no cover
import sys import sys

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "3.1.1" version = "3.2.0"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
@@ -13,9 +13,9 @@ classifiers = [
"Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering",
] ]
dependencies = [ dependencies = [
"bec_ipython_client~=3.106", # needed for jupyter console "bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.106", "bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.3", "bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli "black>=26,<27", # needed for bw-generate-cli
"isort>=5.13, <9.0", # needed for bw-generate-cli "isort>=5.13, <9.0", # needed for bw-generate-cli
"ophyd_devices~=1.29, >=1.29.1", "ophyd_devices~=1.29, >=1.29.1",

View File

@@ -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 # communication should work, main dock area should have same id and be visible
yw = gui.new("Y") yw = gui.new("Y")
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
yw.delete_all() yw.delete_all()
assert len(gui.windows) == 2 assert len(gui.windows) == 2
yw.remove() 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

View File

@@ -72,6 +72,7 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj):
"dap": None, "dap": None,
"device": "bpm4i", "device": "bpm4i",
"signal": "bpm4i", "signal": "bpm4i",
"dap_parameters": None,
"dap_oversample": 1, "dap_oversample": 1,
} }
assert c1._config_dict["source"] == "device" assert c1._config_dict["source"] == "device"

View File

@@ -135,144 +135,160 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
wait_for_namespace_change( wait_for_namespace_change(
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False 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) # @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the LogPanel widget.""" # """Test the BECProgressBar widget."""
# gui = connected_client_gui_obj # gui = connected_client_gui_obj
# bec = gui._client # bec = gui._client
# # Create dock_area and widget # # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel) # widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
# widget: client.LogPanel # 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 # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget.""" # """Test the MotorMap widget."""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper) # widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
widget: client.MineSweeper # 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 # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MotorMap widget.""" # """Test MultiWaveform widget."""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap) # widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
widget: client.MotorMap # widget: client.MultiWaveform
# Test RPC calls # # Test RPC calls
dev = bec.device_manager.devices # dev = bec.device_manager.devices
scans = bec.scans # scans = bec.scans
# Set motor map to names # # test plotting
widget.map(dev.samx, dev.samy) # cm = "cividis"
# Move motor samx to pos # widget.plot(dev.waveform, color_palette=cm)
pos = dev.samx.limits[1] - 1 # -1 from higher limit # assert widget.monitor == dev.waveform.name
scans.mv(dev.samx, pos, relative=False).wait() # assert widget.color_palette == cm
# 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 # # Scan with BEC
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) # 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) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_positioner_indicator(
"""Test MultiWaveform widget.""" # qtbot, connected_client_gui_obj, random_generator_from_seed
gui = connected_client_gui_obj # ):
bec = gui._client # """Test the PositionIndicator widget."""
# Create dock_area and widget # gui = connected_client_gui_obj
widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform) # bec = gui._client
widget: client.MultiWaveform # # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
# widget: client.PositionIndicator
# Test RPC calls # # TODO check what these rpc calls are supposed to do! Issue created #461
dev = bec.device_manager.devices # widget.set_value(5)
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
# Scan with BEC # # Test removing the widget, or leaving it open for the next test
s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
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) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_indicator( # def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
qtbot, connected_client_gui_obj, random_generator_from_seed # """Test the PositionerBox widget."""
): # gui = connected_client_gui_obj
"""Test the PositionIndicator widget.""" # bec = gui._client
gui = connected_client_gui_obj # # Create dock_area and widget
bec = gui._client # widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
# Create dock_area and widget # widget: client.PositionerBox
widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
widget: client.PositionIndicator
# TODO check what these rpc calls are supposed to do! Issue created #461 # # Test rpc calls
widget.set_value(5) # 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 # scans.mv(dev.samy, -3, relative=False).wait()
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) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the PositionerBox widget.""" # """Test the PositionerBox2D widget."""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox) # widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
widget: client.PositionerBox # widget: client.PositionerBox2D
# Test rpc calls # # Test rpc calls
dev = bec.device_manager.devices # dev = bec.device_manager.devices
scans = bec.scans # scans = bec.scans
# No rpc calls to check so far # # No rpc calls to check so far
widget.set_positioner(dev.samx) # widget.set_positioner_hor(dev.samx)
widget.set_positioner(dev.samy.name) # 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 # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_positioner_control_line(
"""Test the PositionerBox2D widget.""" # qtbot, connected_client_gui_obj, random_generator_from_seed
gui = connected_client_gui_obj # ):
bec = gui._client # """Test the positioner control line widget"""
# Create dock_area and widget # gui = connected_client_gui_obj
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D) # bec = gui._client
widget: client.PositionerBox2D # # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
# widget: client.PositionerControlLine
# Test rpc calls # # Test rpc calls
dev = bec.device_manager.devices # dev = bec.device_manager.devices
scans = bec.scans # scans = bec.scans
# No rpc calls to check so far # # Set positioner
widget.set_positioner_hor(dev.samx) # widget.set_positioner(dev.samx)
widget.set_positioner_ver(dev.samy) # 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 # # Test removing the widget, or leaving it open for the next test
scans.mv(dev.samx, 3, relative=False).wait() # 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) @pytest.mark.repeat(20)
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.timeout(PYTEST_TIMEOUT) @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the RingProgressBar widget""" """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) maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the ScanControl widget""" # """Test the ScanControl widget"""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl) # widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
widget: client.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 # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the ScatterWaveform widget""" # """Test the ScatterWaveform widget"""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform) # widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
widget: client.ScatterWaveform # widget: client.ScatterWaveform
# Test rpc calls # # Test rpc calls
dev = bec.device_manager.devices # dev = bec.device_manager.devices
scans = bec.scans # scans = bec.scans
widget.plot(dev.samx, dev.samy, dev.bpm4i) # 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() # 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 # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the TextBox widget""" # """Test the TextBox widget"""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.TextBox) # widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
widget: client.TextBox # widget: client.TextBox
# RPC calls # # RPC calls
widget.set_plain_text("Hello World") # widget.set_plain_text("Hello World")
widget.set_html_text("<b> Hello World HTML </b>") # widget.set_html_text("<b> Hello World HTML </b>")
# Test removing the widget, or leaving it open for the next test # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT) # @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed): # def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the Waveform widget""" # """Test the Waveform widget"""
gui = connected_client_gui_obj # gui = connected_client_gui_obj
bec = gui._client # bec = gui._client
# Create dock_area and widget # # Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.Waveform) # widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
widget: client.Waveform # widget: client.Waveform
# Test rpc calls # # Test rpc calls
dev = bec.device_manager.devices # dev = bec.device_manager.devices
scans = bec.scans # scans = bec.scans
widget.plot(dev.bpm4i) # widget.plot(dev.bpm4i)
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False) # s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait() # s.wait()
def _wait_for_scan_in_history(): # def _wait_for_scan_in_history():
# Get scan item from history # # Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id) # scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None # 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) # scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
samx_data = scan_item.devices.samx.samx.read()["value"] # samx_data = scan_item.devices.samx.samx.read()["value"]
bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"] # bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
curve = widget.curves[0] # curve = widget.curves[0]
assert np.allclose(curve.get_data()[0], samx_data) # assert np.allclose(curve.get_data()[0], samx_data)
assert np.allclose(curve.get_data()[1], bpm4i_data) # assert np.allclose(curve.get_data()[1], bpm4i_data)
# Test removing the widget, or leaving it open for the next test # # 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) # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)

View File

@@ -516,6 +516,112 @@ def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
assert dap_curve.config.signal.dap == "GaussianModel" 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): 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, Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,