1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-09 18:20:55 +02:00

Compare commits

..

50 Commits

Author SHA1 Message Date
733bc04e39 fix(monaco): forward text changed signal 2025-07-24 17:20:43 +02:00
fa06da1ed6 feat(web console): add set_readonly method 2025-07-24 17:20:20 +02:00
bbcefbb88f wip - script interface 2025-07-24 17:19:54 +02:00
a185f6f5fe refactor(script interface): remove reduntanc startup instruction 2025-07-18 17:03:26 +02:00
c63aa01757 feat(web console): add signal to indicate when the js backend is initialized 2025-07-18 17:02:53 +02:00
7e469627a2 wip - script interface 2025-07-17 15:05:32 +02:00
semantic-release
62020f9965 2.27.0
Automatically generated by python-semantic-release
2025-07-17 13:03:53 +00:00
2373c7e996 feat: add monaco editor 2025-07-17 15:02:01 +02:00
semantic-release
1f3566c105 2.26.0
Automatically generated by python-semantic-release
2025-07-17 12:44:47 +00:00
b8ae7b2e96 fix(config label): reset offset when toggling the label action 2025-07-17 14:44:06 +02:00
23674ccf59 fix(performance_bundle): fix performance bundle cleanup 2025-07-17 14:44:06 +02:00
1d8069e391 feat(heatmap): add interpolation and oversampling UI components 2025-07-17 14:44:06 +02:00
44cc06137c test(history): add history message helper methods to conftest 2025-07-17 14:44:06 +02:00
46a91784d2 refactor(image_base): cleanup 2025-07-17 14:44:06 +02:00
debd347b64 feat(device combobox): add option to insert an empty element 2025-07-17 14:44:06 +02:00
semantic-release
a13c3c44c8 2.25.0
Automatically generated by python-semantic-release
2025-07-17 09:27:51 +00:00
25b2737aac refactor: cleanup, add compact popup view for scan_history_browser and update tests 2025-07-17 11:26:57 +02:00
cf97cc1805 refactor: add additional components for history metadata, device view and popup ui 2025-07-17 11:26:57 +02:00
694a6c4960 fix(bec-progressbar): add flag for theme update 2025-07-17 11:26:57 +02:00
9caae4cf40 feat(scan-history-browser): Add history browser and history metadata viewer 2025-07-17 11:26:57 +02:00
2b06e34ecf ci(plugin): add plugin repository test to BW ci 2025-07-15 15:09:53 +02:00
a9c8995ac0 ci(bec): add child_repos test for bec (unit and e2e tests) 2025-07-15 15:09:53 +02:00
semantic-release
1262c66fd6 2.24.1
Automatically generated by python-semantic-release
2025-07-15 09:24:58 +00:00
bde523806f fix: update signal label for device_edit changes 2025-07-15 11:24:12 +02:00
semantic-release
16bca25d9c 2.24.0
Automatically generated by python-semantic-release
2025-07-15 08:30:13 +00:00
130cc24b35 feat(device_browser): connect update to item refresh 2025-07-15 10:29:31 +02:00
8b2d6052e8 fix(device_browser): un-nest exception 2025-07-15 10:29:31 +02:00
530797a556 fix: hide validity LED, show message as tooltip 2025-07-15 10:29:31 +02:00
c660e5141f fix: validate some config data 2025-07-15 10:29:31 +02:00
900153bc0b feat(#495): add validation against existing device names 2025-07-15 10:29:31 +02:00
8dc72656ef feat(device_browser): device deletion from config 2025-07-15 10:29:31 +02:00
170be0c7d3 feat: (#495) add devices through browser 2025-07-15 10:29:31 +02:00
1925e6ac7f docs: docstring for config dialog 2025-07-15 10:29:31 +02:00
semantic-release
b6cef2d27b 2.23.0
Automatically generated by python-semantic-release
2025-07-11 16:44:57 +00:00
a9fce175b7 feat(widget_finder): widget to fetch any other widget by class from currently running app 2025-07-11 18:44:08 +02:00
783d042e8c feat(widget_io): utility function to find widget in the app by class 2025-07-11 18:44:08 +02:00
semantic-release
319a4206f2 2.22.2
Automatically generated by python-semantic-release
2025-07-11 12:43:39 +00:00
76439866c1 fix(plot_base): autorange takes into account only visible curves 2025-07-11 14:42:54 +02:00
semantic-release
ca600b057e 2.22.1
Automatically generated by python-semantic-release
2025-07-11 11:57:47 +00:00
6c494258f8 fix(heatmap): fix pixel size calculation for arbitrary shapes 2025-07-11 13:57:01 +02:00
63a8da680d fix(crosshair): crosshair mouse_moved can be set manually 2025-07-11 13:57:01 +02:00
semantic-release
0f2bde1a0a 2.22.0
Automatically generated by python-semantic-release
2025-07-10 12:23:05 +00:00
0c76b0c495 feat: add heatmap widget 2025-07-10 14:22:15 +02:00
e594de3ca3 fix(image): reset crosshair on new scan 2025-07-10 14:22:15 +02:00
adaad4f4d5 fix(crosshair): add slot to reset mouse markers 2025-07-10 14:22:15 +02:00
39c316d6ea fix(image item): fix processor for nans in images 2025-07-10 14:22:15 +02:00
3ba0fc4b44 fix(crosshair): fix crosshair support for transformations 2025-07-10 14:22:15 +02:00
a6fc7993a3 fix(image_processor): support for nans in nd arrays 2025-07-10 14:22:15 +02:00
324a5bd3d9 feat(image_item): add support for qtransform 2025-07-10 14:22:15 +02:00
8929778f07 fix(image_base): move cbar init to image base 2025-07-10 14:22:15 +02:00
72 changed files with 6569 additions and 245 deletions

64
.github/workflows/child_repos.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Run Pytest with Coverage
on:
workflow_call:
inputs:
BEC_CORE_BRANCH:
description: 'Branch for BEC Core'
required: false
default: 'main'
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch for Ophyd Devices'
required: false
default: 'main'
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch for BEC Widgets'
required: false
default: 'main'
type: string
jobs:
bec:
name: BEC Unit Tests
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Install BEC and dependencies
uses: ./.github/actions/bec_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
PYTHON_VERSION: '3.11'
- name: Run Pytest
run: |
cd ./bec
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
bec-e2e-test:
name: BEC End2End Tests
runs-on: ubuntu-latest
steps:
- name: Checkout BEC
uses: actions/checkout@v4
with:
repository: bec-project/bec
ref: ${{ inputs.BEC_CORE_BRANCH }}
- name: Run E2E Tests
uses: ./.github/actions/bec_e2e_install
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
PYTHON_VERSION: '3.11'

View File

@@ -57,4 +57,24 @@ jobs:
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml
uses: ./.github/workflows/end2end-conda.yml
child-repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}

View File

@@ -1,6 +1,174 @@
# CHANGELOG
## v2.27.0 (2025-07-17)
### Features
- Add monaco editor
([`2373c7e`](https://github.com/bec-project/bec_widgets/commit/2373c7e996566a5b84c5a50e1c3e69de885713db))
## v2.26.0 (2025-07-17)
### Bug Fixes
- **config label**: Reset offset when toggling the label action
([`b8ae7b2`](https://github.com/bec-project/bec_widgets/commit/b8ae7b2e96071b6dc59dae7ffa72bbedc6aaea23))
- **performance_bundle**: Fix performance bundle cleanup
([`23674cc`](https://github.com/bec-project/bec_widgets/commit/23674ccf592a2caa0b57ae64ad1499c270b7d469))
### Features
- **device combobox**: Add option to insert an empty element
([`debd347`](https://github.com/bec-project/bec_widgets/commit/debd347b64a3d2ca07ddcd5ef3a3394d1ffb67e3))
- **heatmap**: Add interpolation and oversampling UI components
([`1d8069e`](https://github.com/bec-project/bec_widgets/commit/1d8069e391412e3096a3c1e7181398dd4e609650))
### Refactoring
- **image_base**: Cleanup
([`46a9178`](https://github.com/bec-project/bec_widgets/commit/46a91784d237137128965ad585e38085e931e5d4))
### Testing
- **history**: Add history message helper methods to conftest
([`44cc061`](https://github.com/bec-project/bec_widgets/commit/44cc06137ccfbc087bdd3005156ff28effe05f23))
## v2.25.0 (2025-07-17)
### Bug Fixes
- **bec-progressbar**: Add flag for theme update
([`694a6c4`](https://github.com/bec-project/bec_widgets/commit/694a6c49608b68e25dc0c76b58855b96f3f0ef0b))
### Continuous Integration
- **bec**: Add child_repos test for bec (unit and e2e tests)
([`a9c8995`](https://github.com/bec-project/bec_widgets/commit/a9c8995ac0b39f6bc327887f43f7d4d6e6e89db2))
- **plugin**: Add plugin repository test to BW ci
([`2b06e34`](https://github.com/bec-project/bec_widgets/commit/2b06e34ecff8c0a92a2b235f375e837729736b2a))
### Features
- **scan-history-browser**: Add history browser and history metadata viewer
([`9caae4c`](https://github.com/bec-project/bec_widgets/commit/9caae4cf40d3876175b827abb735ae227ae0bcea))
### Refactoring
- Add additional components for history metadata, device view and popup ui
([`cf97cc1`](https://github.com/bec-project/bec_widgets/commit/cf97cc1805e16073c7849d1f9375e2ebd2176b70))
- Cleanup, add compact popup view for scan_history_browser and update tests
([`25b2737`](https://github.com/bec-project/bec_widgets/commit/25b2737aacfaa45f255afb6ebf467d5781165a8e))
## v2.24.1 (2025-07-15)
### Bug Fixes
- Update signal label for device_edit changes
([`bde5238`](https://github.com/bec-project/bec_widgets/commit/bde523806fdb6ab224b485f65b615f89dfe20b7b))
## v2.24.0 (2025-07-15)
### Bug Fixes
- Hide validity LED, show message as tooltip
([`530797a`](https://github.com/bec-project/bec_widgets/commit/530797a5568957dde9f47f417310f5c4d2493906))
- Validate some config data
([`c660e51`](https://github.com/bec-project/bec_widgets/commit/c660e5141f191a782c224ee1b83536793639eecb))
- **device_browser**: Un-nest exception
([`8b2d605`](https://github.com/bec-project/bec_widgets/commit/8b2d6052e808f8b4063e5f45c40e4460524f044e))
### Documentation
- Docstring for config dialog
([`1925e6a`](https://github.com/bec-project/bec_widgets/commit/1925e6ac7f98875eb5980637ae3293e22b459e28))
### Features
- (#495) add devices through browser
([`170be0c`](https://github.com/bec-project/bec_widgets/commit/170be0c7d3bb1f6e5f2305958909ef68cd987fbd))
- **#495**: Add validation against existing device names
([`900153b`](https://github.com/bec-project/bec_widgets/commit/900153bc0b8cec7bad82e23b3772c66e84900a17))
- **device_browser**: Connect update to item refresh
([`130cc24`](https://github.com/bec-project/bec_widgets/commit/130cc24b351684358558ab81c0111f10f9abb11f))
- **device_browser**: Device deletion from config
([`8dc7265`](https://github.com/bec-project/bec_widgets/commit/8dc72656ef46ae7be886f9da59beb768f5381b4f))
## v2.23.0 (2025-07-11)
### Features
- **widget_finder**: Widget to fetch any other widget by class from currently running app
([`a9fce17`](https://github.com/bec-project/bec_widgets/commit/a9fce175b720ad85a5cefcab99d79fbcb971ff4a))
- **widget_io**: Utility function to find widget in the app by class
([`783d042`](https://github.com/bec-project/bec_widgets/commit/783d042e8c469774fc8407921462a99c96f6d408))
## v2.22.2 (2025-07-11)
### Bug Fixes
- **plot_base**: Autorange takes into account only visible curves
([`7643986`](https://github.com/bec-project/bec_widgets/commit/76439866c1fd09cb7d9d48dfccdc7b1943bfbc0f))
## v2.22.1 (2025-07-11)
### Bug Fixes
- **crosshair**: Crosshair mouse_moved can be set manually
([`63a8da6`](https://github.com/bec-project/bec_widgets/commit/63a8da680d263a50102aacf463ec6f6252562f9d))
- **heatmap**: Fix pixel size calculation for arbitrary shapes
([`6c49425`](https://github.com/bec-project/bec_widgets/commit/6c494258f82059a2472f43bb8287390ce1aba704))
## v2.22.0 (2025-07-10)
### Bug Fixes
- **crosshair**: Add slot to reset mouse markers
([`adaad4f`](https://github.com/bec-project/bec_widgets/commit/adaad4f4d5ebf775a337e23a944ba9eb289d01a0))
- **crosshair**: Fix crosshair support for transformations
([`3ba0fc4`](https://github.com/bec-project/bec_widgets/commit/3ba0fc4b442e5926f27a13f09d628c30987f2cf8))
- **image**: Reset crosshair on new scan
([`e594de3`](https://github.com/bec-project/bec_widgets/commit/e594de3ca39970f91f5842693eeb1fac393eaa34))
- **image item**: Fix processor for nans in images
([`39c316d`](https://github.com/bec-project/bec_widgets/commit/39c316d6eadfdfbd483661b67720a7e224a46712))
- **image_base**: Move cbar init to image base
([`8929778`](https://github.com/bec-project/bec_widgets/commit/8929778f073c40a9eabba7eda2415fc9af1072bb))
- **image_processor**: Support for nans in nd arrays
([`a6fc799`](https://github.com/bec-project/bec_widgets/commit/a6fc7993a3d22cfd086310c8e6dad3f9f3d1e9fe))
### Features
- Add heatmap widget
([`0c76b0c`](https://github.com/bec-project/bec_widgets/commit/0c76b0c49598d1456aab266b483de327788028fd))
- **image_item**: Add support for qtransform
([`324a5bd`](https://github.com/bec-project/bec_widgets/commit/324a5bd3d9ed278495c6ba62453b02061900ae32))
## v2.21.4 (2025-07-08)
### Bug Fixes

View File

@@ -37,9 +37,11 @@ _Widgets = {
"DeviceBrowser": "DeviceBrowser",
"DeviceComboBox": "DeviceComboBox",
"DeviceLineEdit": "DeviceLineEdit",
"Heatmap": "Heatmap",
"Image": "Image",
"LogPanel": "LogPanel",
"Minesweeper": "Minesweeper",
"MonacoWidget": "MonacoWidget",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PositionIndicator": "PositionIndicator",
@@ -1181,6 +1183,544 @@ class EllipticalROI(RPCBase):
"""
class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
"""
Show Toolbar.
"""
@enable_toolbar.setter
@rpc_call
def enable_toolbar(self) -> "bool":
"""
Show Toolbar.
"""
@property
@rpc_call
def enable_side_panel(self) -> "bool":
"""
Show Side Panel
"""
@enable_side_panel.setter
@rpc_call
def enable_side_panel(self) -> "bool":
"""
Show Side Panel
"""
@property
@rpc_call
def enable_fps_monitor(self) -> "bool":
"""
Enable the FPS monitor.
"""
@enable_fps_monitor.setter
@rpc_call
def enable_fps_monitor(self) -> "bool":
"""
Enable the FPS monitor.
"""
@rpc_call
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@property
@rpc_call
def title(self) -> "str":
"""
Set title of the plot.
"""
@title.setter
@rpc_call
def title(self) -> "str":
"""
Set title of the plot.
"""
@property
@rpc_call
def x_label(self) -> "str":
"""
The set label for the x-axis.
"""
@x_label.setter
@rpc_call
def x_label(self) -> "str":
"""
The set label for the x-axis.
"""
@property
@rpc_call
def y_label(self) -> "str":
"""
The set label for the y-axis.
"""
@y_label.setter
@rpc_call
def y_label(self) -> "str":
"""
The set label for the y-axis.
"""
@property
@rpc_call
def x_limits(self) -> "QPointF":
"""
Get the x limits of the plot.
"""
@x_limits.setter
@rpc_call
def x_limits(self) -> "QPointF":
"""
Get the x limits of the plot.
"""
@property
@rpc_call
def y_limits(self) -> "QPointF":
"""
Get the y limits of the plot.
"""
@y_limits.setter
@rpc_call
def y_limits(self) -> "QPointF":
"""
Get the y limits of the plot.
"""
@property
@rpc_call
def x_grid(self) -> "bool":
"""
Show grid on the x-axis.
"""
@x_grid.setter
@rpc_call
def x_grid(self) -> "bool":
"""
Show grid on the x-axis.
"""
@property
@rpc_call
def y_grid(self) -> "bool":
"""
Show grid on the y-axis.
"""
@y_grid.setter
@rpc_call
def y_grid(self) -> "bool":
"""
Show grid on the y-axis.
"""
@property
@rpc_call
def inner_axes(self) -> "bool":
"""
Show inner axes of the plot widget.
"""
@inner_axes.setter
@rpc_call
def inner_axes(self) -> "bool":
"""
Show inner axes of the plot widget.
"""
@property
@rpc_call
def outer_axes(self) -> "bool":
"""
Show the outer axes of the plot widget.
"""
@outer_axes.setter
@rpc_call
def outer_axes(self) -> "bool":
"""
Show the outer axes of the plot widget.
"""
@property
@rpc_call
def auto_range_x(self) -> "bool":
"""
Set auto range for the x-axis.
"""
@auto_range_x.setter
@rpc_call
def auto_range_x(self) -> "bool":
"""
Set auto range for the x-axis.
"""
@property
@rpc_call
def auto_range_y(self) -> "bool":
"""
Set auto range for the y-axis.
"""
@auto_range_y.setter
@rpc_call
def auto_range_y(self) -> "bool":
"""
Set auto range for the y-axis.
"""
@property
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@minimal_crosshair_precision.setter
@rpc_call
def minimal_crosshair_precision(self) -> "int":
"""
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@property
@rpc_call
def color_map(self) -> "str":
"""
Set the color map of the image.
"""
@color_map.setter
@rpc_call
def color_map(self) -> "str":
"""
Set the color map of the image.
"""
@property
@rpc_call
def v_range(self) -> "QPointF":
"""
Set the v_range of the main image.
"""
@v_range.setter
@rpc_call
def v_range(self) -> "QPointF":
"""
Set the v_range of the main image.
"""
@property
@rpc_call
def v_min(self) -> "float":
"""
Get the minimum value of the v_range.
"""
@v_min.setter
@rpc_call
def v_min(self) -> "float":
"""
Get the minimum value of the v_range.
"""
@property
@rpc_call
def v_max(self) -> "float":
"""
Get the maximum value of the v_range.
"""
@v_max.setter
@rpc_call
def v_max(self) -> "float":
"""
Get the maximum value of the v_range.
"""
@property
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@lock_aspect_ratio.setter
@rpc_call
def lock_aspect_ratio(self) -> "bool":
"""
Whether the aspect ratio is locked.
"""
@property
@rpc_call
def autorange(self) -> "bool":
"""
Whether autorange is enabled.
"""
@autorange.setter
@rpc_call
def autorange(self) -> "bool":
"""
Whether autorange is enabled.
"""
@property
@rpc_call
def autorange_mode(self) -> "str":
"""
Autorange mode.
Options:
- "max": Use the maximum value of the image for autoranging.
- "mean": Use the mean value of the image for autoranging.
"""
@autorange_mode.setter
@rpc_call
def autorange_mode(self) -> "str":
"""
Autorange mode.
Options:
- "max": Use the maximum value of the image for autoranging.
- "mean": Use the mean value of the image for autoranging.
"""
@rpc_call
def enable_colorbar(
self,
enabled: "bool",
style: "Literal['full', 'simple']" = "full",
vrange: "tuple[int, int] | None" = None,
):
"""
Enable the colorbar and switch types of colorbars.
Args:
enabled(bool): Whether to enable the colorbar.
style(Literal["full", "simple"]): The type of colorbar to enable.
vrange(tuple): The range of values to use for the colorbar.
"""
@property
@rpc_call
def enable_simple_colorbar(self) -> "bool":
"""
Enable the simple colorbar.
"""
@enable_simple_colorbar.setter
@rpc_call
def enable_simple_colorbar(self) -> "bool":
"""
Enable the simple colorbar.
"""
@property
@rpc_call
def enable_full_colorbar(self) -> "bool":
"""
Enable the full colorbar.
"""
@enable_full_colorbar.setter
@rpc_call
def enable_full_colorbar(self) -> "bool":
"""
Enable the full colorbar.
"""
@property
@rpc_call
def interpolation_method(self) -> "str":
"""
The interpolation method used for the heatmap.
"""
@interpolation_method.setter
@rpc_call
def interpolation_method(self) -> "str":
"""
The interpolation method used for the heatmap.
"""
@property
@rpc_call
def oversampling_factor(self) -> "float":
"""
The oversampling factor for grid resolution.
"""
@oversampling_factor.setter
@rpc_call
def oversampling_factor(self) -> "float":
"""
The oversampling factor for grid resolution.
"""
@property
@rpc_call
def enforce_interpolation(self) -> "bool":
"""
Whether to enforce interpolation even for grid scans.
"""
@enforce_interpolation.setter
@rpc_call
def enforce_interpolation(self) -> "bool":
"""
Whether to enforce interpolation even for grid scans.
"""
@property
@rpc_call
def fft(self) -> "bool":
"""
Whether FFT postprocessing is enabled.
"""
@fft.setter
@rpc_call
def fft(self) -> "bool":
"""
Whether FFT postprocessing is enabled.
"""
@property
@rpc_call
def log(self) -> "bool":
"""
Whether logarithmic scaling is applied.
"""
@log.setter
@rpc_call
def log(self) -> "bool":
"""
Whether logarithmic scaling is applied.
"""
@property
@rpc_call
def main_image(self) -> "ImageItem":
"""
Access the main image item.
"""
@rpc_call
def add_roi(
self,
kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
name: "str | None" = None,
line_width: "int | None" = 5,
pos: "tuple[float, float] | None" = (10, 10),
size: "tuple[float, float] | None" = (50, 50),
movable: "bool" = True,
**pg_kwargs,
) -> "RectangularROI | CircularROI":
"""
Add a ROI to the image.
Args:
kind(str): The type of ROI to add. Options are "rect" or "circle".
name(str): The name of the ROI.
line_width(int): The line width of the ROI.
pos(tuple): The position of the ROI.
size(tuple): The size of the ROI.
movable(bool): Whether the ROI is movable.
**pg_kwargs: Additional arguments for the ROI.
Returns:
RectangularROI | CircularROI: The created ROI object.
"""
@rpc_call
def remove_roi(self, roi: "int | str"):
"""
Remove an ROI by index or label via the ROIController.
"""
@property
@rpc_call
def rois(self) -> "list[BaseROI]":
"""
Get the list of ROIs.
"""
@rpc_call
def plot(
self,
x_name: "str",
y_name: "str",
z_name: "str",
x_entry: "None | str" = None,
y_entry: "None | str" = None,
z_entry: "None | str" = None,
color_map: "str | None" = "plasma",
validate_bec: "bool" = True,
interpolation: "Literal['linear', 'nearest'] | None" = None,
enforce_interpolation: "bool | None" = None,
oversampling_factor: "float | None" = None,
lock_aspect_ratio: "bool | None" = None,
show_config_label: "bool | None" = None,
reload: "bool" = False,
):
"""
Plot the heatmap with the given x, y, and z data.
Args:
x_name (str): The name of the x-axis signal.
y_name (str): The name of the y-axis signal.
z_name (str): The name of the z-axis signal.
x_entry (str | None): The entry for the x-axis signal.
y_entry (str | None): The entry for the y-axis signal.
z_entry (str | None): The entry for the z-axis signal.
color_map (str | None): The color map to use for the heatmap.
validate_bec (bool): Whether to validate the entries against BEC signals.
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans.
oversampling_factor (float | None): Factor to oversample the grid resolution.
lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image.
show_config_label (bool | None): Whether to show the configuration label in the heatmap.
reload (bool): Whether to reload the heatmap with new data.
"""
class Image(RPCBase):
"""Image widget for displaying 2D data."""
@@ -1879,6 +2419,98 @@ class LogPanel(RPCBase):
class Minesweeper(RPCBase): ...
class MonacoWidget(RPCBase):
"""A simple Monaco editor widget"""
@rpc_call
def set_text(self, text: str) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
"""
@rpc_call
def get_text(self) -> str:
"""
Get the current text from the Monaco editor.
"""
@rpc_call
def set_language(self, language: str) -> None:
"""
Set the programming language for syntax highlighting in the Monaco editor.
Args:
language (str): The programming language to set (e.g., "python", "javascript").
"""
@rpc_call
def get_language(self) -> str:
"""
Get the current programming language set in the Monaco editor.
"""
@rpc_call
def set_theme(self, theme: str) -> None:
"""
Set the theme for the Monaco editor.
Args:
theme (str): The theme to set (e.g., "vs-dark", "light").
"""
@rpc_call
def get_theme(self) -> str:
"""
Get the current theme of the Monaco editor.
"""
@rpc_call
def set_readonly(self, read_only: bool) -> None:
"""
Set the Monaco editor to read-only mode.
Args:
read_only (bool): If True, the editor will be read-only.
"""
@rpc_call
def set_cursor(
self,
line: int,
column: int = 1,
move_to_position: Literal[None, "center", "top", "position"] = None,
) -> None:
"""
Set the cursor position in the Monaco editor.
Args:
line (int): Line number (1-based).
column (int): Column number (1-based), defaults to 1.
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
"""
@rpc_call
def current_cursor(self) -> dict[str, int]:
"""
Get the current cursor position in the Monaco editor.
Returns:
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
"""
@rpc_call
def set_minimap_enabled(self, enabled: bool) -> None:
"""
Enable or disable the minimap in the Monaco editor.
Args:
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@@ -4077,6 +4709,15 @@ class Waveform(RPCBase):
Set auto range for the y-axis.
"""
@rpc_call
def auto_range(self, value: "bool" = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
@property
@rpc_call
def x_log(self) -> "bool":

View File

@@ -9,6 +9,7 @@ from contextlib import redirect_stderr, redirect_stdout
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
@@ -142,6 +143,8 @@ class GUIServer:
"""
Shutdown the GUI server.
"""
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()

View File

@@ -0,0 +1,194 @@
import sys
import uuid
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QFileDialog, QFrame, QSplitter, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
logger = bec_logger.logger
class ScriptInterface(BECWidget, QWidget):
"""
A simple script interface widget that allows interaction with Monaco editor and Web Console.
"""
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
self.current_script_id = ""
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
self.splitter = QSplitter(self)
self.splitter.setObjectName("splitter")
self.splitter.setFrameShape(QFrame.Shape.NoFrame)
self.splitter.setOrientation(Qt.Orientation.Vertical)
self.splitter.setChildrenCollapsible(True)
self.monaco_editor = MonacoWidget(self)
self.splitter.addWidget(self.monaco_editor)
self.web_console = WebConsole(self)
self.splitter.addWidget(self.web_console)
layout.addWidget(self.toolbar)
layout.addWidget(self.splitter)
self.setLayout(layout)
self.toolbar.components.add_safe(
"new_script", MaterialIconAction("add", "New Script", parent=self)
)
self.toolbar.components.add_safe(
"open", MaterialIconAction("folder_open", "Open Script", parent=self)
)
self.toolbar.components.add_safe(
"save", MaterialIconAction("save", "Save Script", parent=self)
)
self.toolbar.components.add_safe(
"run", MaterialIconAction("play_arrow", "Run Script", parent=self)
)
self.toolbar.components.add_safe(
"stop", MaterialIconAction("stop", "Stop Script", parent=self)
)
bundle = ToolbarBundle("file_io", self.toolbar.components)
bundle.add_action("new_script")
bundle.add_action("open")
bundle.add_action("save")
self.toolbar.add_bundle(bundle)
bundle = ToolbarBundle("script_execution", self.toolbar.components)
bundle.add_action("run")
bundle.add_action("stop")
self.toolbar.add_bundle(bundle)
self.toolbar.components.get_action("open").action.triggered.connect(self.open_file_dialog)
self.toolbar.components.get_action("run").action.triggered.connect(self.run_script)
self.toolbar.components.get_action("stop").action.triggered.connect(
self.web_console.send_ctrl_c
)
self.set_save_button_enabled(False)
self.toolbar.show_bundles(["file_io", "script_execution"])
self.web_console.set_readonly(True)
self._init_file_content = ""
self._text_changed_proxy = pg.SignalProxy(
self.monaco_editor.text_changed, rateLimit=1, slot=self._on_text_changed
)
@SafeSlot(str)
def _on_text_changed(self, text: str):
"""
Handle text changes in the Monaco editor.
"""
text = text[0]
if text != self._init_file_content:
self.set_save_button_enabled(True)
else:
self.set_save_button_enabled(False)
@property
def current_script_id(self):
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value):
if not isinstance(value, str):
raise ValueError("Script ID must be a string.")
self._current_script_id = value
self._update_subscription()
def _update_subscription(self):
if self.current_script_id:
self.bec_dispatcher.connect_slot(
self.on_script_execution_info,
MessageEndpoints.script_execution_info(self.current_script_id),
)
else:
self.bec_dispatcher.disconnect_slot(
self.on_script_execution_info,
MessageEndpoints.script_execution_info(self.current_script_id),
)
@SafeSlot(dict, dict)
def on_script_execution_info(self, content: dict, metadata: dict):
print(f"Script execution info: {content}")
current_lines = content.get("current_lines")
if not current_lines:
self.monaco_editor.clear_highlighted_lines()
return
line_number = current_lines[0]
self.monaco_editor.clear_highlighted_lines()
self.monaco_editor.set_highlighted_lines(line_number, line_number)
def open_file_dialog(self):
"""
Open a file dialog to select a script file.
"""
start_dir = "./"
dialog = QFileDialog(self)
dialog.setDirectory(start_dir)
dialog.setNameFilter("Python Files (*.py);;All Files (*)")
dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
if dialog.exec():
selected_files = dialog.selectedFiles()
if not selected_files:
return
file_path = selected_files[0]
with open(file_path, "r", encoding="utf-8") as file:
content = file.read()
self.monaco_editor.set_text(content)
self._init_file_content = content
logger.info(f"Selected files: {selected_files}")
def set_save_button_enabled(self, enabled: bool):
"""
Set the save button enabled state.
"""
action = self.toolbar.components.get_action("save")
if action:
action.action.setEnabled(enabled)
def run_script(self):
print("Running script...")
script_id = str(uuid.uuid4())
self.current_script_id = script_id
script_text = self.monaco_editor.get_text()
script_text = f'bec._run_script("{script_id}", """{script_text}""")'
script_text = script_text.replace("\n", "\\n").replace("'", "\\'").strip()
if not script_text.endswith("\n"):
script_text += "\\n"
self.web_console.write(script_text)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
script_interface = ScriptInterface()
script_interface.resize(800, 600)
script_interface.show()
sys.exit(app.exec_())

View File

@@ -5,9 +5,13 @@ from typing import Any
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtCore import QObject, QPointF, Qt, Signal
from qtpy.QtGui import QCursor, QTransform
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.image.image_item import ImageItem
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
@@ -160,7 +164,7 @@ class Crosshair(QObject):
qapp.theme_signal.theme_updated.connect(self._update_theme)
self._update_theme()
@Slot(str)
@SafeSlot(str)
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -187,7 +191,7 @@ class Crosshair(QObject):
self.coord_label.fill = pg.mkBrush(label_bg_color)
self.coord_label.border = pg.mkPen(None)
@Slot(int)
@SafeSlot(int)
def update_highlighted_curve(self, curve_index: int):
"""
Update the highlighted curve in the case of multiple curves in a plot item.
@@ -265,15 +269,47 @@ class Crosshair(QObject):
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_row.skip_auto_range = True
if item.image_transform is not None:
self.marker_2d_row.setTransform(item.image_transform)
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
if item.image_transform is not None:
self.marker_2d_col.setTransform(item.image_transform)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
@SafeSlot()
def update_markers_on_image_change(self):
"""
Update markers when the image changes, e.g. when the
image shape or transformation changes.
"""
for item in self.items:
if not isinstance(item, pg.ImageItem):
continue
if self.marker_2d_row is not None:
self.marker_2d_row.setSize([item.image.shape[0], 1])
self.marker_2d_row.setTransform(item.image_transform)
if self.marker_2d_col is not None:
self.marker_2d_col.setSize([1, item.image.shape[1]])
self.marker_2d_col.setTransform(item.image_transform)
# Get the current mouse position
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
@@ -316,9 +352,25 @@ class Crosshair(QObject):
image_2d = item.image
if image_2d is None:
continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
# Map scene coordinates (plot units) back to image pixel coordinates
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
xy_trans = inv_transform.map(QPointF(x, y))
else:
xy_trans = QPointF(x, y)
# Define valid pixel coordinate bounds
min_x_px, min_y_px = 0, 0
max_x_px = image_2d.shape[0] - 1 # columns
max_y_px = image_2d.shape[1] - 1 # rows
# Clip the mapped coordinates to the image bounds
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
# Store snapped pixel positions
x_values[name] = px
y_values[name] = py
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
@@ -358,60 +410,74 @@ class Crosshair(QObject):
return list_x[original_index], list_y[original_index]
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
@SafeSlot(object, tuple)
def mouse_moved(self, event=None, manual_pos=None):
"""
Handles the mouse moved event, updating the crosshair position and emitting signals.
Args:
event: The mouse moved event
event(object): The mouse moved event, which contains the scene position.
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
"""
pos = event[0]
# Determine target (x, y) in *plot* coordinates
if manual_pos is not None:
x, y = manual_pos
else:
if event is None:
return # nothing to do
scene_pos = event[0] # SignalProxy bundle
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
return
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
x, y = view_pos.x(), view_pos.y()
# Update crosshair visuals
self.v_line.setPos(x)
self.h_line.setPos(y)
self.update_markers()
if self.plot_item.vb.sceneBoundingRect().contains(pos):
mouse_point = self.plot_item.vb.mapSceneToView(pos)
x, y = mouse_point.x(), mouse_point.y()
self.v_line.setPos(x)
self.h_line.setPos(y)
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
self.crosshairChanged.emit((scaled_x, scaled_y))
self.positionChanged.emit((x, y))
x_snap_values, y_snap_values = self.snap_to_data(x, y)
if x_snap_values is None or y_snap_values is None:
return
if all(v is None for v in x_snap_values.values()) or all(
v is None for v in y_snap_values.values()
):
# not sure how we got here, but just to be safe...
return
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
if snap_x_vals is None or snap_y_vals is None:
return
if all(v is None for v in snap_x_vals.values()) or all(
v is None for v in snap_y_vals.values()
):
return
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
self.marker_moved_1d[name].setData([x], [y])
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
coordinate_to_emit = (
name,
round(x_snapped_scaled, precision),
round(y_snapped_scaled, precision),
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
precision = self._current_precision()
for item in self.items:
if isinstance(item, pg.PlotDataItem):
name = item.name() or str(id(item))
sx, sy = snap_x_vals[name], snap_y_vals[name]
if sx is None or sy is None:
continue
self.marker_moved_1d[name].setData([sx], [sy])
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
self.coordinatesChanged1D.emit(
(name, round(sx_s, precision), round(sy_s, precision))
)
elif isinstance(item, pg.ImageItem):
name = item.objectName() or str(id(item))
px, py = snap_x_vals[name], snap_y_vals[name]
if px is None or py is None:
continue
# Respect image transforms
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(px, py, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, py])
self.marker_2d_col.setPos([px, 0])
self.coordinatesChanged2D.emit((name, px, py))
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
@@ -462,15 +528,35 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
"""
Maps the given x and y coordinates to the transformed position using the provided transform.
Args:
x (float): The x-coordinate to transform.
y (float): The y-coordinate to transform.
transform (QTransform): The transformation to apply.
"""
origin = transform.map(QPointF(0, 0))
row = transform.map(QPointF(0, y)) - origin
col = transform.map(QPointF(x, 0)) - origin
return row, col
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
@@ -512,8 +598,18 @@ class Crosshair(QObject):
image = item.image
if image is None:
continue
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
pt = inv_transform.map(QPointF(x, y))
px, py = pt.x(), pt.y()
else:
px, py = x, y
# Clip to valid pixel indices
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}"
break
@@ -533,15 +629,19 @@ class Crosshair(QObject):
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
self.clear_markers()
def cleanup(self):
@SafeSlot()
def reset(self):
"""Resets the crosshair to its initial state."""
if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.clear_markers()
def cleanup(self):
self.reset()
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)
self.clear_markers()

View File

@@ -171,8 +171,9 @@ class TypedForm(BECWidget, QWidget):
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
form_data_updated = Signal(dict)
form_data_cleared = Signal(NoneType)
validity_proc = Signal(bool)
def __init__(
self,
@@ -204,7 +205,7 @@ class PydanticModelForm(TypedForm):
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata validity" # type: ignore
self._validity.label = "Validity" # type: ignore
self._validity.compact_show_popup.setIcon(
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
)
@@ -264,16 +265,18 @@ class PydanticModelForm(TypedForm):
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
Otherwise, emits on form_data_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
self.form_data_updated.emit(metadata_dict)
self.validity_proc.emit(True)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
return False

View File

@@ -390,17 +390,29 @@ class ListFormItem(DynamicFormItem):
def _add_buttons(self):
self._button_holder = QWidget()
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._buttons = QVBoxLayout()
self._buttons.setContentsMargins(0, 0, 0, 0)
self._button_holder.setLayout(self._buttons)
self._layout.addWidget(self._button_holder)
self._add_remove_button_holder = QWidget()
self._add_remove_button_layout = QHBoxLayout()
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
self._add_button = QPushButton("+")
self._add_button.setMinimumHeight(15)
self._add_button.setToolTip("add a new row")
self._remove_button = QPushButton("-")
self._remove_button.setMinimumHeight(15)
self._remove_button.setToolTip("delete the focused row (if any)")
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._delete_row)
self._buttons.addWidget(self._add_button)
self._buttons.addWidget(self._remove_button)
self._buttons.addWidget(self._add_remove_button_holder)
self._add_remove_button_layout.addWidget(self._add_button)
self._add_remove_button_layout.addWidget(self._remove_button)
def _set_pretty_display(self):
super()._set_pretty_display()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -42,11 +43,15 @@ class PerformanceConnection(BundleConnection):
super().__init__()
self._connected = False
@SafeSlot(bool)
def set_fps_monitor(self, enabled: bool):
setattr(self.target_widget, "enable_fps_monitor", enabled)
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
self.set_fps_monitor
)
def disconnect(self):
@@ -54,5 +59,6 @@ class PerformanceConnection(BundleConnection):
return
# Disconnect the action from the target widget's method
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
lambda checked: setattr(self.target_widget, "enable_fps_monitor", checked)
self.set_fps_monitor
)
self._connected = False

View File

@@ -264,6 +264,48 @@ class WidgetIO:
return WidgetIO._handlers[base]
return None
@staticmethod
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
"""
Return widgets matching the given class (or class-name string).
Args:
widget_class: Either a QWidget subclass or its class-name as a string.
recursive: If True (default), traverse all top-level widgets and their children;
if False, scan app.allWidgets() for a flat list.
Returns:
List of QWidget instances matching the class or class-name.
"""
app = QApplication.instance()
if app is None:
raise RuntimeError("No QApplication instance found.")
# Match by class-name string
if isinstance(widget_class, str):
name = widget_class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if top.__class__.__name__ == name:
result.append(top)
result.extend(
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
)
return result
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
# Match by actual class
if recursive:
result: list[QWidget] = []
for top in app.topLevelWidgets():
if isinstance(top, widget_class):
result.append(top)
result.extend(top.findChildren(widget_class))
return result
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
################## for exporting and importing widget hierarchies ##################

View File

@@ -28,6 +28,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
@@ -154,6 +155,9 @@ class BECDockArea(BECWidget, QWidget):
filled=True,
parent=self,
),
"heatmap": MaterialIconAction(
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
),
},
),
)
@@ -291,6 +295,9 @@ class BECDockArea(BECWidget, QWidget):
menu_plots.actions["motor_map"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
menu_plots.actions["heatmap"].action.triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
)
# Menu Devices
menu_devices.actions["scan_control"].action.triggered.connect(

View File

@@ -69,7 +69,7 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal (str): signal name.
"""
if self.validate_signal(signal) is True:
if self.validate_signal(signal):
WidgetIO.set_value(widget=self, value=signal)
self.config.default = signal
else:

View File

@@ -5,6 +5,7 @@ from qtpy.QtGui import QPainter, QPaintEvent, QPen
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
@@ -61,6 +62,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
self._set_first_element_as_empty = False
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
@@ -93,6 +95,31 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def on_device_update(self, action: str, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
from bec_lib.device import Positioner
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QComboBox, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
@@ -54,6 +56,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
self._set_first_element_as_empty = True
# We do not consider the config that is passed here, this produced problems
# with QtDesigner, since config and input arguments may differ and resolve properly
# Implementing this logic and config recoverage is postponed.
@@ -90,6 +93,31 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
@SafeProperty(bool)
def set_first_element_as_empty(self) -> bool:
"""
Whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
"""
return self._set_first_element_as_empty
@set_first_element_as_empty.setter
def set_first_element_as_empty(self, value: bool) -> None:
"""
Set whether the first element in the combobox should be empty.
This is useful to allow the user to select a device from the list.
Args:
value (bool): True if the first element should be empty, False otherwise.
"""
self._set_first_element_as_empty = value
if self._set_first_element_as_empty:
self.insertItem(0, "")
self.setCurrentIndex(0)
else:
if self.count() > 0 and self.itemText(0) == "":
self.removeItem(0)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
@@ -142,6 +170,10 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return
self.device_signal_changed.emit(text)
@property
def selected_signal_comp_name(self) -> str:
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel

View File

@@ -169,8 +169,8 @@ class ScanControl(BECWidget, QWidget):
self.layout.addWidget(self._metadata_form)
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form()
def populate_scans(self):

View File

@@ -0,0 +1,191 @@
from typing import Literal
import qtmonaco
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_name
class MonacoWidget(BECWidget, QWidget):
"""
A simple Monaco editor widget
"""
text_changed = Signal(str)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
"set_text",
"get_text",
"set_language",
"get_language",
"set_theme",
"get_theme",
"set_readonly",
"set_cursor",
"current_cursor",
"set_minimap_enabled",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.editor = qtmonaco.Monaco(self)
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.initialized.connect(self.apply_theme)
def apply_theme(self, theme: str | None = None) -> None:
"""
Apply the current theme to the Monaco editor.
Args:
theme (str, optional): The theme to apply. If None, the current theme will be used.
"""
if theme is None:
theme = get_theme_name()
editor_theme = "vs" if theme == "light" else "vs-dark"
self.set_theme(editor_theme)
def set_text(self, text: str) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
"""
self.editor.set_text(text)
def get_text(self) -> str:
"""
Get the current text from the Monaco editor.
"""
return self.editor.get_text()
def set_cursor(
self,
line: int,
column: int = 1,
move_to_position: Literal[None, "center", "top", "position"] = None,
) -> None:
"""
Set the cursor position in the Monaco editor.
Args:
line (int): Line number (1-based).
column (int): Column number (1-based), defaults to 1.
move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to.
"""
self.editor.set_cursor(line, column, move_to_position)
def current_cursor(self) -> dict[str, int]:
"""
Get the current cursor position in the Monaco editor.
Returns:
dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position.
"""
return self.editor.current_cursor
def set_language(self, language: str) -> None:
"""
Set the programming language for syntax highlighting in the Monaco editor.
Args:
language (str): The programming language to set (e.g., "python", "javascript").
"""
self.editor.set_language(language)
def get_language(self) -> str:
"""
Get the current programming language set in the Monaco editor.
"""
return self.editor.get_language()
def set_readonly(self, read_only: bool) -> None:
"""
Set the Monaco editor to read-only mode.
Args:
read_only (bool): If True, the editor will be read-only.
"""
self.editor.set_readonly(read_only)
def set_theme(self, theme: str) -> None:
"""
Set the theme for the Monaco editor.
Args:
theme (str): The theme to set (e.g., "vs-dark", "light").
"""
self.editor.set_theme(theme)
def get_theme(self) -> str:
"""
Get the current theme of the Monaco editor.
"""
return self.editor.get_theme()
def set_minimap_enabled(self, enabled: bool) -> None:
"""
Enable or disable the minimap in the Monaco editor.
Args:
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
self.editor.set_minimap_enabled(enabled)
def set_highlighted_lines(self, start_line: int, end_line: int) -> None:
"""
Highlight a range of lines in the Monaco editor.
Args:
start_line (int): The starting line number (1-based).
end_line (int): The ending line number (1-based).
"""
self.editor.set_highlighted_lines(start_line, end_line)
def clear_highlighted_lines(self) -> None:
"""
Clear any highlighted lines in the Monaco editor.
"""
self.editor.clear_highlighted_lines()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])
widget = MonacoWidget()
# set the default size
widget.resize(800, 600)
widget.set_language("python")
widget.set_theme("vs-dark")
widget.editor.set_minimap_enabled(False)
widget.set_text(
"""
import numpy as np
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bec_lib.devicemanager import DeviceContainer
from bec_lib.scans import Scans
dev: DeviceContainer
scans: Scans
#######################################
########## User Script #####################
#######################################
# This is a comment
def hello_world():
print("Hello, world!")
"""
)
widget.set_highlighted_lines(1, 3)
widget.show()
qapp.exec_()

View File

@@ -0,0 +1 @@
{'files': ['monaco_widget.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
DOM_XML = """
<ui language='c++'>
<widget class='MonacoWidget' name='monaco_widget'>
</widget>
</ui>
"""
class MonacoWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = MonacoWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Developer"
def icon(self):
return designer_material_icon(MonacoWidget.ICON_NAME)
def includeFile(self):
return "monaco_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "MonacoWidget"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.editors.monaco.monaco_widget_plugin import MonacoWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MonacoWidgetPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -6,11 +6,12 @@ import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
@@ -165,11 +166,16 @@ class WebConsole(BECWidget, QWidget):
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._startup_cmd = "bec --nogui"
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
@@ -181,6 +187,48 @@ class WebConsole(BECWidget, QWidget):
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
self._startup_timer = QTimer()
self._startup_timer.setInterval(1000)
self._startup_timer.timeout.connect(self._check_page_ready)
self._startup_timer.start()
self._js_callback.connect(self._on_js_callback)
def _check_page_ready(self):
"""
Check if the page is ready and stop the timer if it is.
"""
if self.page.isLoading():
return
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
def _on_js_callback(self, ready: bool):
"""
Callback for when the JavaScript is ready.
"""
if not ready:
return
self._is_initialized = True
self._startup_timer.stop()
if self._startup_cmd:
self.write(self._startup_cmd)
self.initialized.emit()
@SafeProperty(str)
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the web console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
def write(self, data: str, send_return: bool = True):
"""
@@ -213,10 +261,19 @@ class WebConsole(BECWidget, QWidget):
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
self._startup_timer.stop()
_web_console_registry.unregister(self)
super().cleanup()

View File

@@ -0,0 +1,989 @@
from __future__ import annotations
import json
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QTimer, Signal
from qtpy.QtGui import QTransform
from scipy.interpolate import (
CloughTocher2DInterpolator,
LinearNDInterpolator,
NearestNDInterpolator,
)
from scipy.spatial import cKDTree
from toolz import partition
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.plots.heatmap.settings.heatmap_setting import HeatmapSettings
from bec_widgets.widgets.plots.image.image_base import ImageBase
from bec_widgets.widgets.plots.image.image_item import ImageItem
logger = bec_logger.logger
class HeatmapDeviceSignal(BaseModel):
"""The configuration of a signal in the scatter waveform widget."""
name: str
entry: str
model_config: dict = {"validate_assignment": True}
class HeatmapConfig(ConnectionConfig):
parent_id: str | None = Field(None, description="The parent plot of the curve.")
color_map: str | None = Field(
"plasma", description="The color palette of the heatmap widget.", validate_default=True
)
color_bar: Literal["full", "simple"] | None = Field(
None, description="The type of the color bar."
)
interpolation: Literal["linear", "nearest", "clough"] = Field(
"linear", description="The interpolation method for the heatmap."
)
oversampling_factor: float = Field(
1.0,
description="Factor to oversample the grid resolution (1.0 = no oversampling, 2.0 = 2x resolution).",
)
show_config_label: bool = Field(
True, description="Whether to show the configuration label in the heatmap."
)
enforce_interpolation: bool = Field(
False, description="Whether to use the interpolation mode even for grid scans."
)
lock_aspect_ratio: bool = Field(
False, description="Whether to lock the aspect ratio of the image."
)
x_device: HeatmapDeviceSignal | None = Field(
None, description="The x device signal of the heatmap."
)
y_device: HeatmapDeviceSignal | None = Field(
None, description="The y device signal of the heatmap."
)
z_device: HeatmapDeviceSignal | None = Field(
None, description="The z device signal of the heatmap."
)
model_config: dict = {"validate_assignment": True}
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
class Heatmap(ImageBase):
"""
Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.
"""
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
# ImageView Specific Settings
"color_map",
"color_map.setter",
"v_range",
"v_range.setter",
"v_min",
"v_min.setter",
"v_max",
"v_max.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"autorange",
"autorange.setter",
"autorange_mode",
"autorange_mode.setter",
"enable_colorbar",
"enable_simple_colorbar",
"enable_simple_colorbar.setter",
"enable_full_colorbar",
"enable_full_colorbar.setter",
"interpolation_method",
"interpolation_method.setter",
"oversampling_factor",
"oversampling_factor.setter",
"enforce_interpolation",
"enforce_interpolation.setter",
"fft",
"fft.setter",
"log",
"log.setter",
"main_image",
"add_roi",
"remove_roi",
"rois",
"plot",
]
PLUGIN = True
RPC = True
ICON_NAME = "dataset"
new_scan = Signal()
new_scan_id = Signal(str)
sync_signal_update = Signal()
heatmap_property_changed = Signal()
def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs):
if config is None:
config = HeatmapConfig(
widget_class=self.__class__.__name__,
parent_id=None,
color_map="plasma",
color_bar=None,
interpolation="linear",
oversampling_factor=1.0,
lock_aspect_ratio=False,
x_device=None,
y_device=None,
z_device=None,
)
super().__init__(parent=parent, config=config, theme_update=True, **kwargs)
self._image_config = config
self.scan_id = None
self.old_scan_id = None
self.scan_item = None
self.status_message = None
self._grid_index = None
self.heatmap_dialog = None
bg_color = pg.mkColor((240, 240, 240, 150))
self.config_label = pg.LegendItem(
labelTextColor=(0, 0, 0), offset=(-30, 1), brush=pg.mkBrush(bg_color), horSpacing=0
)
self.config_label.setParentItem(self.plot_item.vb)
self.config_label.setVisible(False)
self.reload = False
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
self.heatmap_property_changed.connect(lambda: self.sync_signal_update.emit())
self.proxy_update_sync = pg.SignalProxy(
self.sync_signal_update, rateLimit=5, slot=self.update_plot
)
self._init_toolbar_heatmap()
self.toolbar.show_bundles(
[
"heatmap_settings",
"plot_export",
"image_crosshair",
"mouse_interaction",
"image_autorange",
"image_colorbar",
"image_processing",
"axis_popup",
"interpolation_info",
]
)
@property
def main_image(self) -> ImageItem:
"""Access the main image item."""
return self.layer_manager["main"].image
################################################################################
# Widget Specific GUI interactions
################################################################################
@SafeSlot(str)
def apply_theme(self, theme: str):
"""
Apply the current theme to the heatmap widget.
"""
super().apply_theme(theme)
if theme == "dark":
brush = pg.mkBrush(pg.mkColor(50, 50, 50, 150))
color = pg.mkColor(255, 255, 255)
else:
brush = pg.mkBrush(pg.mkColor(240, 240, 240, 150))
color = pg.mkColor(0, 0, 0)
if hasattr(self, "config_label"):
self.config_label.setBrush(brush)
self.config_label.setLabelTextColor(color)
self.redraw_config_label()
@SafeSlot(popup_error=True)
def plot(
self,
x_name: str,
y_name: str,
z_name: str,
x_entry: None | str = None,
y_entry: None | str = None,
z_entry: None | str = None,
color_map: str | None = "plasma",
validate_bec: bool = True,
interpolation: Literal["linear", "nearest"] | None = None,
enforce_interpolation: bool | None = None,
oversampling_factor: float | None = None,
lock_aspect_ratio: bool | None = None,
show_config_label: bool | None = None,
reload: bool = False,
):
"""
Plot the heatmap with the given x, y, and z data.
Args:
x_name (str): The name of the x-axis signal.
y_name (str): The name of the y-axis signal.
z_name (str): The name of the z-axis signal.
x_entry (str | None): The entry for the x-axis signal.
y_entry (str | None): The entry for the y-axis signal.
z_entry (str | None): The entry for the z-axis signal.
color_map (str | None): The color map to use for the heatmap.
validate_bec (bool): Whether to validate the entries against BEC signals.
interpolation (Literal["linear", "nearest"] | None): The interpolation method to use.
enforce_interpolation (bool | None): Whether to enforce interpolation even for grid scans.
oversampling_factor (float | None): Factor to oversample the grid resolution.
lock_aspect_ratio (bool | None): Whether to lock the aspect ratio of the image.
show_config_label (bool | None): Whether to show the configuration label in the heatmap.
reload (bool): Whether to reload the heatmap with new data.
"""
if validate_bec:
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
if x_entry is None or y_entry is None or z_entry is None:
raise ValueError("x, y, and z entries must be provided.")
if x_name is None or y_name is None or z_name is None:
raise ValueError("x, y, and z names must be provided.")
if interpolation is None:
interpolation = self._image_config.interpolation
if oversampling_factor is None:
oversampling_factor = self._image_config.oversampling_factor
if enforce_interpolation is None:
enforce_interpolation = self._image_config.enforce_interpolation
if lock_aspect_ratio is None:
lock_aspect_ratio = self._image_config.lock_aspect_ratio
if show_config_label is None:
show_config_label = self._image_config.show_config_label
self._image_config = HeatmapConfig(
parent_id=self.gui_id,
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
y_device=HeatmapDeviceSignal(name=y_name, entry=y_entry),
z_device=HeatmapDeviceSignal(name=z_name, entry=z_entry),
color_map=color_map,
color_bar=None,
interpolation=interpolation,
oversampling_factor=oversampling_factor,
enforce_interpolation=enforce_interpolation,
lock_aspect_ratio=lock_aspect_ratio,
show_config_label=show_config_label,
)
self.color_map = color_map
self.reload = reload
self.update_labels()
self._fetch_running_scan()
self.sync_signal_update.emit()
def _fetch_running_scan(self):
scan = self.client.queue.scan_storage.current_scan
if scan is not None:
self.scan_item = scan
self.scan_id = scan.scan_id
elif self.client.history and len(self.client.history) > 0:
self.scan_item = self.client.history[-1]
self.scan_id = self.client.history._scan_ids[-1]
self.old_scan_id = None
def update_labels(self):
"""
Update the labels of the x, y, and z axes.
"""
if self._image_config is None:
return
x_name = self._image_config.x_device.name
y_name = self._image_config.y_device.name
z_name = self._image_config.z_device.name
if x_name is not None:
self.x_label = x_name # type: ignore
x_dev = self.dev.get(x_name)
if x_dev and hasattr(x_dev, "egu"):
self.x_label_units = x_dev.egu()
if y_name is not None:
self.y_label = y_name # type: ignore
y_dev = self.dev.get(y_name)
if y_dev and hasattr(y_dev, "egu"):
self.y_label_units = y_dev.egu()
if z_name is not None:
self.title = z_name
def _init_toolbar_heatmap(self):
"""
Initialize the toolbar for the heatmap widget, adding actions for heatmap settings.
"""
self.toolbar.add_action(
"heatmap_settings",
MaterialIconAction(
icon_name="scatter_plot",
tooltip="Show Heatmap Settings",
checkable=True,
parent=self,
),
)
self.toolbar.components.get_action("heatmap_settings").action.triggered.connect(
self.show_heatmap_settings
)
# disable all processing actions except for the fft and log
bundle = self.toolbar.get_bundle("image_processing")
for name, action in bundle.bundle_actions.items():
if name not in ["image_processing_fft", "image_processing_log"]:
action().action.setVisible(False)
self.toolbar.add_action(
"interpolation_info",
MaterialIconAction(
icon_name="info", tooltip="Show Interpolation Info", checkable=True, parent=self
),
)
self.toolbar.components.get_action("interpolation_info").action.triggered.connect(
self.toggle_interpolation_info
)
self.toolbar.components.get_action("interpolation_info").action.setChecked(
self._image_config.show_config_label
)
def show_heatmap_settings(self):
"""
Show the heatmap settings dialog.
"""
heatmap_settings_action = self.toolbar.components.get_action("heatmap_settings").action
if self.heatmap_dialog is None or not self.heatmap_dialog.isVisible():
heatmap_settings = HeatmapSettings(parent=self, target_widget=self, popup=True)
self.heatmap_dialog = SettingsDialog(
self, settings_widget=heatmap_settings, window_title="Heatmap Settings", modal=False
)
self.heatmap_dialog.resize(700, 350)
# When the dialog is closed, update the toolbar icon and clear the reference
self.heatmap_dialog.finished.connect(self._heatmap_dialog_closed)
self.heatmap_dialog.show()
heatmap_settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.heatmap_dialog.raise_()
self.heatmap_dialog.activateWindow()
heatmap_settings_action.setChecked(True) # keep it toggled
def toggle_interpolation_info(self):
"""
Toggle the visibility of the interpolation info label.
"""
self._image_config.show_config_label = not self._image_config.show_config_label
self.toolbar.components.get_action("interpolation_info").action.setChecked(
self._image_config.show_config_label
)
self.redraw_config_label()
def _heatmap_dialog_closed(self):
"""
Slot for when the heatmap settings dialog is closed.
"""
self.heatmap_dialog = None
self.toolbar.components.get_action("heatmap_settings").action.setChecked(False)
@SafeSlot(dict, dict)
def on_scan_status(self, msg: dict, meta: dict):
"""
Initial scan status message handler, which is triggered at the begging and end of scan.
Args:
msg(dict): The message content.
meta(dict): The message metadata.
"""
current_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.reset()
self.new_scan.emit()
self.new_scan_id.emit(current_scan_id)
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # type: ignore
# First trigger to update the scan curves
self.sync_signal_update.emit()
@SafeSlot(dict, dict)
def on_scan_progress(self, msg: dict, meta: dict):
self.sync_signal_update.emit()
status = msg.get("done")
if status:
QTimer.singleShot(100, self.update_plot)
QTimer.singleShot(300, self.update_plot)
@SafeSlot(verify_sender=True)
def update_plot(self, _=None) -> None:
"""
Update the plot with the current data.
"""
if self.scan_item is None:
logger.info("No scan executed so far; skipping update.")
return
data, access_key = self._fetch_scan_data_and_access()
if data == "none":
logger.info("No scan executed so far; skipping update.")
return
if self._image_config is None:
return
try:
x_name = self._image_config.x_device.name
x_entry = self._image_config.x_device.entry
y_name = self._image_config.y_device.name
y_entry = self._image_config.y_device.entry
z_name = self._image_config.z_device.name
z_entry = self._image_config.z_device.entry
except AttributeError:
return
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
if not isinstance(x_data, list):
x_data = x_data.tolist() if isinstance(x_data, np.ndarray) else None
if not isinstance(y_data, list):
y_data = y_data.tolist() if isinstance(y_data, np.ndarray) else None
if not isinstance(z_data, list):
z_data = z_data.tolist() if isinstance(z_data, np.ndarray) else None
if x_data is None or y_data is None or z_data is None:
logger.warning("x, y, or z data is None; skipping update.")
return
if len(x_data) != len(y_data) or len(x_data) != len(z_data):
logger.warning(
"x, y, and z data lengths do not match; skipping update. "
f"Lengths: x={len(x_data)}, y={len(y_data)}, z={len(z_data)}"
)
return
if hasattr(self.scan_item, "status_message"):
scan_msg = self.scan_item.status_message
elif hasattr(self.scan_item, "metadata"):
metadata = self.scan_item.metadata["bec"]
status = metadata["exit_status"]
scan_id = metadata["scan_id"]
scan_name = metadata["scan_name"]
scan_type = metadata["scan_type"]
scan_number = metadata["scan_number"]
request_inputs = metadata["request_inputs"]
if "arg_bundle" in request_inputs and isinstance(request_inputs["arg_bundle"], str):
# Convert the arg_bundle from a JSON string to a dictionary
request_inputs["arg_bundle"] = json.loads(request_inputs["arg_bundle"])
positions = metadata.get("positions", [])
positions = positions.tolist() if isinstance(positions, np.ndarray) else positions
scan_msg = messages.ScanStatusMessage(
status=status,
scan_id=scan_id,
scan_name=scan_name,
scan_number=scan_number,
scan_type=scan_type,
request_inputs=request_inputs,
info={"positions": positions},
)
else:
scan_msg = None
if scan_msg is None:
logger.warning("Scan message is None; skipping update.")
return
self.status_message = scan_msg
if self._image_config.show_config_label:
self.redraw_config_label()
img, transform = self.get_image_data(x_data=x_data, y_data=y_data, z_data=z_data)
if img is None:
logger.warning("Image data is None; skipping update.")
return
if self._color_bar is not None:
self._color_bar.blockSignals(True)
self.main_image.set_data(img, transform=transform)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
self.image_updated.emit()
if self.crosshair is not None:
self.crosshair.update_markers_on_image_change()
def redraw_config_label(self):
scan_msg = self.status_message
if scan_msg is None:
return
if not self._image_config.show_config_label:
self.config_label.setVisible(False)
return
self.config_label.setOffset((-30, 1))
self.config_label.setVisible(True)
self.config_label.clear()
self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number}")
self.config_label.addItem(self.plot_item, f"Scan Name: {scan_msg.scan_name}")
if scan_msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
self.config_label.addItem(
self.plot_item, f"Interpolation: {self._image_config.interpolation}"
)
self.config_label.addItem(
self.plot_item, f"Oversampling: {self._image_config.oversampling_factor}x"
)
def get_image_data(
self,
x_data: list[float] | None = None,
y_data: list[float] | None = None,
z_data: list[float] | None = None,
) -> tuple[np.ndarray | None, QTransform | None]:
"""
Get the image data for the heatmap. Depending on the scan type, it will
either pre-allocate the grid (grid_scan) or interpolate the data (step scan).
Args:
x_data (np.ndarray): The x data.
y_data (np.ndarray): The y data.
z_data (np.ndarray): The z data.
msg (messages.ScanStatusMessage): The scan status message.
Returns:
tuple[np.ndarray, QTransform]: The image data and the QTransform.
"""
msg = self.status_message
if x_data is None or y_data is None or z_data is None or msg is None:
logger.warning("x, y, or z data is None; skipping update.")
return None, None
if msg.scan_name == "grid_scan" and not self._image_config.enforce_interpolation:
# We only support the grid scan mode if both scanning motors
# are configured in the heatmap config.
device_x = self._image_config.x_device.entry
device_y = self._image_config.y_device.entry
if (
device_x in msg.request_inputs["arg_bundle"]
and device_y in msg.request_inputs["arg_bundle"]
):
return self.get_grid_scan_image(z_data, msg)
if len(z_data) < 4:
# LinearNDInterpolator requires at least 4 points to interpolate
return None, None
return self.get_step_scan_image(x_data, y_data, z_data, msg)
def get_grid_scan_image(
self, z_data: list[float], msg: messages.ScanStatusMessage
) -> tuple[np.ndarray, QTransform]:
"""
Get the image data for a grid scan.
Args:
z_data (np.ndarray): The z data.
msg (messages.ScanStatusMessage): The scan status message.
Returns:
tuple[np.ndarray, QTransform]: The image data and the QTransform.
"""
args = self.arg_bundle_to_dict(4, msg.request_inputs["arg_bundle"])
shape = (
args[self._image_config.x_device.entry][-1],
args[self._image_config.y_device.entry][-1],
)
data = self.main_image.raw_data
if data is None or data.shape != shape:
data = np.empty(shape)
data.fill(np.nan)
def _get_grid_data(axis, snaked=True):
x_grid, y_grid = np.meshgrid(axis[0], axis[1])
if snaked:
y_grid.T[::2] = np.fliplr(y_grid.T[::2])
x_flat = x_grid.T.ravel()
y_flat = y_grid.T.ravel()
positions = np.vstack((x_flat, y_flat)).T
return positions
snaked = msg.request_inputs["kwargs"].get("snaked", True)
# If the scan's fast axis is x, we need to swap the x and y axes
swap = bool(msg.request_inputs["arg_bundle"][4] == self._image_config.x_device.entry)
# calculate the QTransform to put (0,0) at the axis origin
scan_pos = np.asarray(msg.info["positions"])
x_min = min(scan_pos[:, 0])
x_max = max(scan_pos[:, 0])
y_min = min(scan_pos[:, 1])
y_max = max(scan_pos[:, 1])
x_range = x_max - x_min
y_range = y_max - y_min
pixel_size_x = x_range / (shape[0] - 1)
pixel_size_y = y_range / (shape[1] - 1)
transform = QTransform()
if swap:
transform.scale(pixel_size_y, pixel_size_x)
transform.translate(y_min / pixel_size_y - 0.5, x_min / pixel_size_x - 0.5)
else:
transform.scale(pixel_size_x, pixel_size_y)
transform.translate(x_min / pixel_size_x - 0.5, y_min / pixel_size_y - 0.5)
target_positions = _get_grid_data(
(np.arange(shape[int(swap)]), np.arange(shape[int(not swap)])), snaked=snaked
)
# Fill the data array with the z values
if self._grid_index is None or self.reload:
self._grid_index = 0
self.reload = False
for i in range(self._grid_index, len(z_data)):
data[target_positions[i, int(swap)], target_positions[i, int(not swap)]] = z_data[i]
self._grid_index = len(z_data)
return data, transform
def get_step_scan_image(
self,
x_data: list[float],
y_data: list[float],
z_data: list[float],
msg: messages.ScanStatusMessage,
) -> tuple[np.ndarray, QTransform]:
"""
Get the image data for an arbitrary step scan.
Args:
x_data (list[float]): The x data.
y_data (list[float]): The y data.
z_data (list[float]): The z data.
msg (messages.ScanStatusMessage): The scan status message.
Returns:
tuple[np.ndarray, QTransform]: The image data and the QTransform.
"""
xy_data = np.column_stack((x_data, y_data))
grid_x, grid_y, transform = self.get_image_grid(xy_data)
# Interpolate the z data onto the grid
if self._image_config.interpolation == "linear":
interp = LinearNDInterpolator(xy_data, z_data)
elif self._image_config.interpolation == "nearest":
interp = NearestNDInterpolator(xy_data, z_data)
elif self._image_config.interpolation == "clough":
interp = CloughTocher2DInterpolator(xy_data, z_data)
else:
raise ValueError(
"Interpolation method must be either 'linear', 'nearest', or 'clough'."
)
grid_z = interp(grid_x, grid_y)
return grid_z, transform
def get_image_grid(self, positions) -> tuple[np.ndarray, np.ndarray, QTransform]:
"""
LRU-cached calculation of the grid for the image. The lru cache is indexed by the scan_id
to avoid recalculating the grid for the same scan.
Args:
_scan_id (str): The scan ID. Needed for caching but not used in the function.
Returns:
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
"""
base_width, base_height = self.estimate_image_resolution(positions)
# Apply oversampling factor
factor = self._image_config.oversampling_factor
# Apply oversampling
width = int(base_width * factor)
height = int(base_height * factor)
# Create grid
grid_x, grid_y = np.mgrid[
min(positions[:, 0]) : max(positions[:, 0]) : width * 1j,
min(positions[:, 1]) : max(positions[:, 1]) : height * 1j,
]
# Calculate transform
x_min, x_max = min(positions[:, 0]), max(positions[:, 0])
y_min, y_max = min(positions[:, 1]), max(positions[:, 1])
x_range = x_max - x_min
y_range = y_max - y_min
x_scale = x_range / width
y_scale = y_range / height
transform = QTransform()
transform.scale(x_scale, y_scale)
transform.translate(x_min / x_scale - 0.5, y_min / y_scale - 0.5)
return grid_x, grid_y, transform
@staticmethod
def estimate_image_resolution(coords: np.ndarray) -> tuple[int, int]:
"""
Estimate the number of pixels needed for the image based on the coordinates.
Args:
coords (np.ndarray): The coordinates of the points.
Returns:
tuple[int, int]: The estimated width and height of the image."""
if coords.ndim != 2 or coords.shape[1] != 2:
raise ValueError("Input must be an (m x 2) array of (x, y) coordinates.")
x_min, x_max = coords[:, 0].min(), coords[:, 0].max()
y_min, y_max = coords[:, 1].min(), coords[:, 1].max()
tree = cKDTree(coords)
distances, _ = tree.query(coords, k=2)
distances = distances[:, 1] # Get the second nearest neighbor distance
avg_distance = np.mean(distances)
width_extent = x_max - x_min
height_extent = y_max - y_min
# Calculate the number of pixels needed based on the average distance
width_pixels = int(np.ceil(width_extent / avg_distance))
height_pixels = int(np.ceil(height_extent / avg_distance))
return max(1, width_pixels), max(1, height_pixels)
def arg_bundle_to_dict(self, bundle_size: int, args: list) -> dict:
"""
Convert the argument bundle to a dictionary.
Args:
args (list): The argument bundle.
Returns:
dict: The dictionary representation of the argument bundle.
"""
params = {}
for cmds in partition(bundle_size, args):
params[cmds[0]] = list(cmds[1:])
return params
def _fetch_scan_data_and_access(self):
"""
Decide whether the widget is in live or historical mode
and return the appropriate data dict and access key.
Returns:
data_dict (dict): The data structure for the current scan.
access_key (str): Either 'val' (live) or 'value' (history).
"""
if self.scan_item is None:
# Optionally fetch the latest from history if nothing is set
# self.update_with_scan_history(-1)
if self.scan_item is None:
logger.info("No scan executed so far; skipping update.")
return "none", "none"
if hasattr(self.scan_item, "live_data"):
# Live scan
return self.scan_item.live_data, "val"
# Historical
scan_devices = self.scan_item.devices
return scan_devices, "value"
def reset(self):
self._grid_index = None
self.main_image.clear()
if self.crosshair is not None:
self.crosshair.reset()
super().reset()
@SafeProperty(str)
def interpolation_method(self) -> str:
"""
The interpolation method used for the heatmap.
"""
return self._image_config.interpolation
@interpolation_method.setter
def interpolation_method(self, value: str):
"""
Set the interpolation method for the heatmap.
Args:
value(str): The interpolation method, either 'linear' or 'nearest'.
"""
if value not in ["linear", "nearest"]:
raise ValueError("Interpolation method must be either 'linear' or 'nearest'.")
self._image_config.interpolation = value
self.heatmap_property_changed.emit()
@SafeProperty(float)
def oversampling_factor(self) -> float:
"""
The oversampling factor for grid resolution.
"""
return self._image_config.oversampling_factor
@oversampling_factor.setter
def oversampling_factor(self, value: float):
"""
Set the oversampling factor for grid resolution.
Args:
value(float): The oversampling factor (1.0 = no oversampling, 2.0 = 2x resolution).
"""
if value <= 0:
raise ValueError("Oversampling factor must be greater than 0.")
self._image_config.oversampling_factor = value
self.heatmap_property_changed.emit()
@SafeProperty(bool)
def enforce_interpolation(self) -> bool:
"""
Whether to enforce interpolation even for grid scans.
"""
return self._image_config.enforce_interpolation
@enforce_interpolation.setter
def enforce_interpolation(self, value: bool):
"""
Set whether to enforce interpolation even for grid scans.
Args:
value(bool): Whether to enforce interpolation.
"""
self._image_config.enforce_interpolation = value
self.heatmap_property_changed.emit()
################################################################################
# Post Processing
################################################################################
@SafeProperty(bool)
def fft(self) -> bool:
"""
Whether FFT postprocessing is enabled.
"""
return self.main_image.fft
@fft.setter
def fft(self, enable: bool):
"""
Set FFT postprocessing.
Args:
enable(bool): Whether to enable FFT postprocessing.
"""
self.main_image.fft = enable
@SafeProperty(bool)
def log(self) -> bool:
"""
Whether logarithmic scaling is applied.
"""
return self.main_image.log
@log.setter
def log(self, enable: bool):
"""
Set logarithmic scaling.
Args:
enable(bool): Whether to enable logarithmic scaling.
"""
self.main_image.log = enable
@SafeProperty(int)
def num_rotation_90(self) -> int:
"""
The number of 90° rotations to apply counterclockwise.
"""
return self.main_image.num_rotation_90
@num_rotation_90.setter
def num_rotation_90(self, value: int):
"""
Set the number of 90° rotations to apply counterclockwise.
Args:
value(int): The number of 90° rotations to apply.
"""
self.main_image.num_rotation_90 = value
@SafeProperty(bool)
def transpose(self) -> bool:
"""
Whether the image is transposed.
"""
return self.main_image.transpose
@transpose.setter
def transpose(self, enable: bool):
"""
Set the image to be transposed.
Args:
enable(bool): Whether to enable transposing the image.
"""
self.main_image.transpose = enable
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
heatmap = Heatmap()
heatmap.plot(x_name="samx", y_name="samy", z_name="bpm4i", oversampling_factor=5.0)
heatmap.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1 @@
{'files': ['heatmap.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
DOM_XML = """
<ui language='c++'>
<widget class='Heatmap' name='heatmap'>
</widget>
</ui>
"""
class HeatmapPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Heatmap(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Plot Widgets"
def icon(self):
return designer_material_icon(Heatmap.ICON_NAME)
def includeFile(self):
return "heatmap"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "Heatmap"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots.heatmap.heatmap_plugin import HeatmapPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(HeatmapPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,188 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
from bec_widgets.utils import UILoader
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget
if TYPE_CHECKING:
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
SignalComboBox,
)
class HeatmapSettings(SettingWidget):
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# This is a settings widget that depends on the target widget
# and should mirror what is in the target widget.
# Saving settings for this widget could result in recursively setting the target widget.
self.setProperty("skip_settings", True)
current_path = os.path.dirname(__file__)
if popup:
form = UILoader().load_ui(
os.path.join(current_path, "heatmap_settings_horizontal.ui"), self
)
else:
form = UILoader().load_ui(
os.path.join(current_path, "heatmap_settings_vertical.ui"), self
)
self.target_widget = target_widget
self.popup = popup
# # Scroll area
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setWidget(form)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.scroll_area)
self.ui = form
self.fetch_all_properties()
self.target_widget.heatmap_property_changed.connect(self.fetch_all_properties)
if popup is False:
self.ui.button_apply.clicked.connect(self.accept_changes)
self.ui.x_name.setFocus()
@SafeSlot()
def fetch_all_properties(self):
"""
Fetch all properties from the target widget and update the settings widget.
"""
if not self.target_widget:
return
# Get properties from the target widget
color_map = getattr(self.target_widget, "color_map", None)
# Default values for device properties
x_name, x_entry = None, None
y_name, y_entry = None, None
z_name, z_entry = None, None
# Safely access device properties
if hasattr(self.target_widget, "_image_config") and self.target_widget._image_config:
config = self.target_widget._image_config
if hasattr(config, "x_device") and config.x_device:
x_name = getattr(config.x_device, "name", None)
x_entry = getattr(config.x_device, "entry", None)
if hasattr(config, "y_device") and config.y_device:
y_name = getattr(config.y_device, "name", None)
y_entry = getattr(config.y_device, "entry", None)
if hasattr(config, "z_device") and config.z_device:
z_name = getattr(config.z_device, "name", None)
z_entry = getattr(config.z_device, "entry", None)
# Apply the properties to the settings widget
if hasattr(self.ui, "color_map"):
self.ui.color_map.colormap = color_map
if hasattr(self.ui, "x_name"):
self.ui.x_name.set_device(x_name)
if hasattr(self.ui, "x_entry") and x_entry is not None:
self.ui.x_entry.set_to_obj_name(x_entry)
if hasattr(self.ui, "y_name"):
self.ui.y_name.set_device(y_name)
if hasattr(self.ui, "y_entry") and y_entry is not None:
self.ui.y_entry.set_to_obj_name(y_entry)
if hasattr(self.ui, "z_name"):
self.ui.z_name.set_device(z_name)
if hasattr(self.ui, "z_entry") and z_entry is not None:
self.ui.z_entry.set_to_obj_name(z_entry)
if hasattr(self.ui, "interpolation"):
self.ui.interpolation.setCurrentText(
getattr(self.target_widget._image_config, "interpolation", "linear")
)
if hasattr(self.ui, "oversampling_factor"):
self.ui.oversampling_factor.setValue(
getattr(self.target_widget._image_config, "oversampling_factor", 1.0)
)
if hasattr(self.ui, "enforce_interpolation"):
self.ui.enforce_interpolation.setChecked(
getattr(self.target_widget._image_config, "enforce_interpolation", False)
)
def _get_signal_name(self, signal: SignalComboBox) -> str:
"""
Get the signal name from the signal combobox.
Args:
signal (SignalComboBox): The signal combobox to get the name from.
Returns:
str: The signal name.
"""
device_entry = signal.currentText()
index = signal.findText(device_entry)
if index == -1:
return device_entry
device_entry_info = signal.itemData(index)
if device_entry_info:
device_entry = device_entry_info.get("obj_name", device_entry)
return device_entry if device_entry else ""
@SafeSlot()
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
x_name = self.ui.x_name.currentText()
x_entry = self._get_signal_name(self.ui.x_entry)
y_name = self.ui.y_name.currentText()
y_entry = self._get_signal_name(self.ui.y_entry)
z_name = self.ui.z_name.currentText()
z_entry = self._get_signal_name(self.ui.z_entry)
validate_bec = self.ui.validate_bec.checked
color_map = self.ui.color_map.colormap
interpolation = self.ui.interpolation.currentText()
oversampling_factor = self.ui.oversampling_factor.value()
enforce_interpolation = self.ui.enforce_interpolation.isChecked()
self.target_widget.plot(
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color_map=color_map,
validate_bec=validate_bec,
interpolation=interpolation,
oversampling_factor=oversampling_factor,
enforce_interpolation=enforce_interpolation,
reload=True,
)
def cleanup(self):
self.ui.x_name.close()
self.ui.x_name.deleteLater()
self.ui.x_entry.close()
self.ui.x_entry.deleteLater()
self.ui.y_name.close()
self.ui.y_name.deleteLater()
self.ui.y_entry.close()
self.ui.y_entry.deleteLater()
self.ui.z_name.close()
self.ui.z_name.deleteLater()
self.ui.z_entry.close()
self.ui.z_entry.deleteLater()
self.ui.interpolation.close()
self.ui.interpolation.deleteLater()

View File

@@ -0,0 +1,433 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>826</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QGroupBox" name="groupBox_4">
<property name="title">
<string>Interpolation</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="ToggleSwitch" name="enforce_interpolation">
<property name="toolTip">
<string>Use the interpolation mode even for grid scans</string>
</property>
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_9">
<property name="toolTip">
<string>Use the interpolation mode even for grid scans</string>
</property>
<property name="text">
<string>Enforce Interpolation</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QDoubleSpinBox" name="oversampling_factor">
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>1.000000000000000</double>
</property>
<property name="maximum">
<double>10.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QComboBox" name="interpolation">
<property name="minimumSize">
<size>
<width>100</width>
<height>26</height>
</size>
</property>
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>nearest</string>
</property>
</item>
<item>
<property name="text">
<string>clough</string>
</property>
</item>
</widget>
</item>
<item row="5" column="1">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Oversampling</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="label_8">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>Interpolation Method</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>16</height>
</size>
</property>
</spacer>
</item>
<item alignment="Qt::AlignmentFlag::AlignRight">
<widget class="QGroupBox" name="groupBox_5">
<property name="title">
<string>General</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="2" column="1">
<widget class="QLabel" name="label_7">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>Validate BEC</string>
</property>
</widget>
</item>
<item row="2" column="3" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="ToggleSwitch" name="validate_bec"/>
</item>
<item row="3" column="3" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="BECColorMapWidget" name="color_map">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>50</height>
</size>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Colormap</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>X Device</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="x_name">
<property name="editable">
<bool>true</bool>
</property>
<property name="set_first_element_as_empty" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="x_entry">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Y Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="y_name">
<property name="editable">
<bool>true</bool>
</property>
<property name="set_first_element_as_empty" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="y_entry">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Z Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="z_name">
<property name="editable">
<bool>true</bool>
</property>
<property name="set_first_element_as_empty" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="z_entry">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceComboBox</class>
<extends>QComboBox</extends>
<header>device_combobox</header>
</customwidget>
<customwidget>
<class>SignalComboBox</class>
<extends>QComboBox</extends>
<header>signal_combo_box</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>y_name</tabstop>
<tabstop>z_name</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_entry</tabstop>
<tabstop>interpolation</tabstop>
<tabstop>oversampling_factor</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<signal>device_reset()</signal>
<receiver>x_entry</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>254</x>
<y>226</y>
</hint>
<hint type="destinationlabel">
<x>254</x>
<y>267</y>
</hint>
</hints>
</connection>
<connection>
<sender>x_name</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>x_entry</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>254</x>
<y>226</y>
</hint>
<hint type="destinationlabel">
<x>254</x>
<y>267</y>
</hint>
</hints>
</connection>
<connection>
<sender>y_name</sender>
<signal>device_reset()</signal>
<receiver>y_entry</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>526</x>
<y>226</y>
</hint>
<hint type="destinationlabel">
<x>526</x>
<y>267</y>
</hint>
</hints>
</connection>
<connection>
<sender>y_name</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>y_entry</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>526</x>
<y>226</y>
</hint>
<hint type="destinationlabel">
<x>526</x>
<y>267</y>
</hint>
</hints>
</connection>
<connection>
<sender>z_name</sender>
<signal>device_reset()</signal>
<receiver>z_entry</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>798</x>
<y>226</y>
</hint>
<hint type="destinationlabel">
<x>798</x>
<y>267</y>
</hint>
</hints>
</connection>
<connection>
<sender>z_name</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>z_entry</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>798</x>
<y>226</y>
</hint>
<hint type="destinationlabel">
<x>798</x>
<y>267</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,374 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>305</width>
<height>629</height>
</rect>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>629</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="button_apply">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
<item>
<widget class="BECColorMapWidget" name="color_map"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Validate BEC</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="validate_bec">
<property name="checked" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>X Device</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="x_name">
<property name="editable">
<bool>true</bool>
</property>
<property name="set_first_element_as_empty" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="x_entry">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Y Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="y_name">
<property name="editable">
<bool>true</bool>
</property>
<property name="set_first_element_as_empty" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="y_entry">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Z Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceComboBox" name="z_name">
<property name="editable">
<bool>true</bool>
</property>
<property name="set_first_element_as_empty" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="SignalComboBox" name="z_entry">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_4">
<property name="title">
<string>Interpolation</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Interpolation Method</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="interpolation">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>nearest</string>
</property>
</item>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Enforce Interpolation</string>
</property>
</widget>
</item>
<item row="0" column="1" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="ToggleSwitch" name="enforce_interpolation">
<property name="enabled">
<bool>true</bool>
</property>
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Oversampling</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="oversampling_factor">
<property name="minimum">
<double>1.000000000000000</double>
</property>
<property name="maximum">
<double>10.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceComboBox</class>
<extends>QComboBox</extends>
<header>device_combobox</header>
</customwidget>
<customwidget>
<class>SignalComboBox</class>
<extends>QComboBox</extends>
<header>signal_combo_box</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>x_name</tabstop>
<tabstop>y_name</tabstop>
<tabstop>z_name</tabstop>
<tabstop>button_apply</tabstop>
<tabstop>x_entry</tabstop>
<tabstop>y_entry</tabstop>
<tabstop>z_entry</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<signal>device_reset()</signal>
<receiver>x_entry</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>113</x>
<y>178</y>
</hint>
<hint type="destinationlabel">
<x>110</x>
<y>183</y>
</hint>
</hints>
</connection>
<connection>
<sender>x_name</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>x_entry</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>160</x>
<y>178</y>
</hint>
<hint type="destinationlabel">
<x>159</x>
<y>188</y>
</hint>
</hints>
</connection>
<connection>
<sender>y_name</sender>
<signal>device_reset()</signal>
<receiver>y_entry</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>92</x>
<y>278</y>
</hint>
<hint type="destinationlabel">
<x>92</x>
<y>287</y>
</hint>
</hints>
</connection>
<connection>
<sender>y_name</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>y_entry</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>136</x>
<y>277</y>
</hint>
<hint type="destinationlabel">
<x>135</x>
<y>290</y>
</hint>
</hints>
</connection>
<connection>
<sender>z_name</sender>
<signal>device_reset()</signal>
<receiver>z_entry</receiver>
<slot>reset_selection()</slot>
<hints>
<hint type="sourcelabel">
<x>106</x>
<y>376</y>
</hint>
<hint type="destinationlabel">
<x>112</x>
<y>397</y>
</hint>
</hints>
</connection>
<connection>
<sender>z_name</sender>
<signal>currentTextChanged(QString)</signal>
<receiver>z_entry</receiver>
<slot>set_device(QString)</slot>
<hints>
<hint type="sourcelabel">
<x>164</x>
<y>376</y>
</hint>
<hint type="destinationlabel">
<x>168</x>
<y>389</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -140,7 +140,6 @@ class Image(ImageBase):
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
self.gui_id = config.gui_id
self._color_bar = None
self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(
lambda: ImageLayerConfig(monitor=None, monitor_type="auto", source="auto")
)
@@ -566,6 +565,8 @@ class Image(ImageBase):
self.main_image.clear()
self.main_image.buffer = []
self.main_image.max_len = 0
if self.crosshair is not None:
self.crosshair.reset()
image_buffer = self.adjust_image_buffer(self.main_image, data)
if self._color_bar is not None:
self._color_bar.blockSignals(True)

View File

@@ -260,6 +260,7 @@ class ImageBase(PlotBase):
"""
self.x_roi = None
self.y_roi = None
self._color_bar = None
super().__init__(*args, **kwargs)
self.roi_controller = ROIController(colormap="viridis")
@@ -566,7 +567,9 @@ class ImageBase(PlotBase):
"""
# Create ROI plot widgets
self.x_roi = ImageROIPlot(parent=self)
self.x_roi.plot_item.setXLink(self.plot_item)
self.y_roi = ImageROIPlot(parent=self)
self.y_roi.plot_item.setYLink(self.plot_item)
self.x_roi.apply_theme("dark")
self.y_roi.apply_theme("dark")
@@ -637,7 +640,8 @@ class ImageBase(PlotBase):
else:
x = coordinates[1]
y = coordinates[2]
image = self.layer_manager["main"].image.image
image_item = self.layer_manager["main"].image
image = image_item.image
if image is None:
return
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
@@ -646,14 +650,27 @@ class ImageBase(PlotBase):
return
# Horizontal slice
h_slice = image[:, col]
x_axis = np.arange(h_slice.shape[0])
x_pixel_indices = np.arange(h_slice.shape[0])
if image_item.image_transform is None:
h_world_x = np.arange(h_slice.shape[0])
else:
h_world_x = [
image_item.image_transform.map(xi + 0.5, col + 0.5)[0] for xi in x_pixel_indices
]
self.x_roi.plot_item.clear()
self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
# Vertical slice
v_slice = image[row, :]
y_axis = np.arange(v_slice.shape[0])
y_pixel_indices = np.arange(v_slice.shape[0])
if image_item.image_transform is None:
v_world_y = np.arange(v_slice.shape[0])
else:
v_world_y = [
image_item.image_transform.map(row + 0.5, yi + 0.5)[1] for yi in y_pixel_indices
]
self.y_roi.plot_item.clear()
self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3))
################################################################################
# Widget Specific Properties
@@ -865,7 +882,6 @@ class ImageBase(PlotBase):
enabled(bool): Whether to enable autorange.
sync(bool): Whether to synchronize the autorange state across all layers.
"""
print(f"Setting autorange to {enabled}")
for layer in self.layer_manager:
if not layer.sync.autorange:
continue
@@ -897,7 +913,6 @@ class ImageBase(PlotBase):
Args:
mode(str): The autorange mode. Options are "max" or "mean".
"""
print(f"Setting autorange mode to {mode}")
# for qt Designer
if mode not in ["max", "mean"]:
return
@@ -919,7 +934,6 @@ class ImageBase(PlotBase):
"""
if not self.layer_manager:
return
print(f"Toggling autorange to {enabled} with mode {mode}")
for layer in self.layer_manager:
if layer.sync.autorange:
layer.image.autorange = enabled
@@ -993,6 +1007,7 @@ class ImageBase(PlotBase):
"""
Cleanup the widget.
"""
self.toolbar.cleanup()
# Remove all ROIs
rois = self.rois

View File

@@ -7,6 +7,7 @@ import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal
from qtpy.QtGui import QTransform
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
from bec_widgets.widgets.plots.image.image_processor import (
@@ -85,6 +86,7 @@ class ImageItem(BECConnector, pg.ImageItem):
self.set_parent(parent_image)
else:
self.parent_image = None
self.image_transform = None
super().__init__(config=config, gui_id=gui_id, **kwargs)
self.raw_data = None
@@ -100,8 +102,9 @@ class ImageItem(BECConnector, pg.ImageItem):
def parent(self):
return self.parent_image
def set_data(self, data: np.ndarray):
def set_data(self, data: np.ndarray, transform: QTransform | None = None):
self.raw_data = data
self.image_transform = transform
self._process_image()
################################################################################
@@ -210,12 +213,19 @@ class ImageItem(BECConnector, pg.ImageItem):
"""
Reprocess the current raw data and update the image display.
"""
if self.raw_data is not None:
autorange = self.config.autorange
self._image_processor.set_config(self.config.processing)
processed_data = self._image_processor.process_image(self.raw_data)
self.setImage(processed_data, autoLevels=False)
self.autorange = autorange
if self.raw_data is None:
return
if np.all(np.isnan(self.raw_data)):
return
autorange = self.config.autorange
self._image_processor.set_config(self.config.processing)
processed_data = self._image_processor.process_image(self.raw_data)
self.setImage(processed_data, autoLevels=False)
if self.image_transform is not None:
self.setTransform(self.image_transform)
self.autorange = autorange
@property
def fft(self) -> bool:

View File

@@ -27,7 +27,12 @@ class ImageStats:
Returns:
ImageStats: The statistics of the image data.
"""
return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data))
return cls(
maximum=np.nanmax(data),
minimum=np.nanmin(data),
mean=np.nanmean(data),
std=np.nanstd(data),
)
# noinspection PyDataclass
@@ -81,7 +86,7 @@ class ImageProcessor(QObject):
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
return np.abs(np.fft.fftshift(np.fft.fft2(np.nan_to_num(data))))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""

View File

@@ -881,6 +881,38 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.enableAutoRange(y=value)
def auto_range(self, value: bool = True):
"""
On demand apply autorange to the plot item based on the visible curves.
Args:
value(bool): If True, apply autorange to the visible curves.
"""
if not value:
self.plot_item.enableAutoRange(x=False, y=False)
return
self._apply_autorange_only_visible_curves()
def _fetch_visible_curves(self):
"""
Fetch all visible curves from the plot item.
"""
visible_curves = []
for curve in self.plot_item.curves:
if curve.isVisible():
visible_curves.append(curve)
return visible_curves
def _apply_autorange_only_visible_curves(self):
"""
Apply autorange to the plot item based on the provided curves.
Args:
curves (list): List of curves to apply autorange to.
"""
visible_curves = self._fetch_visible_curves()
self.plot_item.autoRange(items=visible_curves if visible_curves else None)
@SafeProperty(int, doc="The font size of the legend font.")
def legend_label_size(self) -> int:
"""
@@ -1008,6 +1040,7 @@ class PlotBase(BECWidget, QWidget):
self.crosshair.update_markers()
def cleanup(self):
self.toolbar.cleanup()
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=True)
self.tick_item.cleanup()
@@ -1017,7 +1050,6 @@ class PlotBase(BECWidget, QWidget):
self.axis_settings_dialog = None
self.cleanup_pyqtgraph()
self.round_plot_widget.close()
self.toolbar.cleanup()
super().cleanup()
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):

View File

@@ -165,5 +165,4 @@ class MouseInteractionConnection(BundleConnection):
Enable autorange on the plot widget.
"""
if self.target_widget:
self.target_widget.auto_range_x = True
self.target_widget.auto_range_y = True
self.target_widget.auto_range()

View File

@@ -96,6 +96,7 @@ class Waveform(PlotBase):
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"auto_range",
"x_log",
"x_log.setter",
"y_log",
@@ -1109,8 +1110,7 @@ class Waveform(PlotBase):
self.reset()
self.new_scan.emit()
self.new_scan_id.emit(current_scan_id)
self.auto_range_x = True
self.auto_range_y = True
self.auto_range(True)
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan

View File

@@ -62,7 +62,9 @@ class BECProgressBar(BECWidget, QWidget):
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
accent_colors = get_accent_colors()
@@ -89,7 +91,14 @@ class BECProgressBar(BECWidget, QWidget):
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = dict(PROGRESS_STATE_COLORS)
# self._state_colors = dict(PROGRESS_STATE_COLORS)
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
# layout settings
self._padding_left_right = 10
@@ -127,6 +136,16 @@ class BECProgressBar(BECWidget, QWidget):
"""
return self._label_template
def apply_theme(self, theme=None):
"""Apply the current theme to the progress bar."""
accent_colors = get_accent_colors()
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
@label_template.setter
def label_template(self, template):
self._label_template = template

View File

@@ -3,10 +3,12 @@ import re
from functools import partial
from bec_lib.callback_handler import EventType
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from bec_qthemes import material_icon
from pyqtgraph import SignalProxy
from qtpy.QtCore import QSize, Signal
from qtpy.QtCore import QSize, QThreadPool, Signal
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -14,6 +16,9 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
logger = bec_logger.logger
@@ -24,7 +29,8 @@ class DeviceBrowser(BECWidget, QWidget):
DeviceBrowser is a widget that displays all available devices in the current BEC session.
"""
device_update: Signal = Signal()
devices_changed: Signal = Signal()
device_update: Signal = Signal(str, dict)
PLUGIN = True
ICON_NAME = "lists"
@@ -38,6 +44,8 @@ class DeviceBrowser(BECWidget, QWidget):
) -> None:
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
self._q_threadpool = QThreadPool()
self.ui = None
self.ini_ui()
self.dev_list: QListWidget = self.ui.device_list
@@ -48,7 +56,9 @@ class DeviceBrowser(BECWidget, QWidget):
self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_update.connect(self.update_device_list)
self.devices_changed.connect(self.update_device_list)
self.ui.add_button.clicked.connect(self._create_add_dialog)
self.ui.add_button.setIcon(material_icon("add", size=(20, 20), convert_to_pixmap=False))
self.init_device_list()
self.update_device_list()
@@ -63,6 +73,10 @@ class DeviceBrowser(BECWidget, QWidget):
layout.addWidget(self.ui)
self.setLayout(layout)
def _create_add_dialog(self):
dialog = DeviceConfigDialog(parent=self, device=None, action="add")
dialog.open()
def on_device_update(self, action: ConfigAction, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
@@ -72,35 +86,49 @@ class DeviceBrowser(BECWidget, QWidget):
content (dict): The content of the config update.
"""
if action in ["add", "remove", "reload"]:
self.device_update.emit()
self.devices_changed.emit()
if action in ["update", "reload"]:
self.device_update.emit(action, content)
def init_device_list(self):
self.dev_list.clear()
self._device_items: dict[str, QListWidgetItem] = {}
with RPCRegister.delayed_broadcast():
for device, device_obj in self.dev.items():
self._add_item_to_list(device, device_obj)
def _add_item_to_list(self, device: str, device_obj):
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
device_item.adjustSize()
item.setSizeHint(QSize(device_item.width(), device_item.height()))
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
with RPCRegister.delayed_broadcast():
for device, device_obj in self.dev.items():
item = QListWidgetItem(self.dev_list)
device_item = DeviceItem(
parent=self,
device=device,
devices=self.dev,
icon=map_device_type_to_icon(device_obj),
)
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
device_item.broadcast_size_hint.connect(item.setSizeHint)
item.setSizeHint(device_item.sizeHint())
def _remove_item(item: QListWidgetItem):
self.dev_list.takeItem(self.dev_list.row(item))
del self._device_items[device]
self.dev_list.sortItems()
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
item = QListWidgetItem(self.dev_list)
device_item = DeviceItem(
parent=self,
device=device,
devices=self.dev,
icon=map_device_type_to_icon(device_obj),
config_helper=self._config_helper,
q_threadpool=self._q_threadpool,
)
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
device_item.imminent_deletion.connect(partial(_remove_item, item))
self.device_update.connect(device_item.config_update)
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
device_item.broadcast_size_hint.connect(item.setSizeHint)
item.setSizeHint(device_item.sizeHint())
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
@SafeSlot()
def reset_device_list(self) -> None:
@@ -119,6 +147,10 @@ class DeviceBrowser(BECWidget, QWidget):
Either way, the function will filter the devices based on the filter input text and update the device list.
"""
filter_text = self.ui.filter_input.text()
for device in self.dev:
if device not in self._device_items:
# it is possible the device has just been added to the config
self._add_item_to_list(device, self.dev[device])
try:
self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error:

View File

@@ -29,6 +29,31 @@
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="button_box">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolButton" name="add_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>

View File

@@ -0,0 +1,60 @@
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from qtpy.QtCore import QObject, QRunnable, Signal
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class _CommSignals(QObject):
error = Signal(Exception)
done = Signal()
class CommunicateConfigAction(QRunnable):
def __init__(
self,
config_helper: ConfigHelper,
device: str | None,
config: dict | None,
action: ConfigAction,
) -> None:
super().__init__()
self.config_helper = config_helper
self.device = device
self.config = config
self.action = action
self.signals = _CommSignals()
@SafeSlot()
def run(self):
try:
if self.action in ["add", "update", "remove"]:
if (dev_name := self.device or self.config.get("name")) is None:
raise ValueError(
"Must be updating a device or be supplied a name for a new device"
)
req_args = {
"action": self.action,
"config": {dev_name: self.config},
"wait_for_response": False,
}
timeout = (
self.config_helper.suggested_timeout_s(self.config)
if self.config is not None
else 20
)
RID = self.config_helper.send_config_request(**req_args)
logger.info("Waiting for config reply")
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
self.config_helper.handle_update_reply(reply, RID, timeout)
logger.info("Done updating config!")
else:
raise ValueError(f"action {self.action} is not supported")
except Exception as e:
self.signals.error.emit(e)
else:
self.signals.done.emit()

View File

@@ -1,10 +1,12 @@
from ast import literal_eval
from typing import Literal
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
from pydantic import ValidationError, field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
from qtpy.QtWidgets import (
QApplication,
QDialog,
@@ -17,6 +19,9 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
@@ -25,35 +30,13 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
class _CommSignals(QObject):
error = Signal(Exception)
done = Signal()
class _CommunicateUpdate(QRunnable):
def __init__(self, config_helper: ConfigHelper, device: str, config: dict) -> None:
super().__init__()
self.config_helper = config_helper
self.device = device
self.config = config
self.signals = _CommSignals()
@SafeSlot()
def run(self):
try:
timeout = self.config_helper.suggested_timeout_s(self.config)
RID = self.config_helper.send_config_request(
action="update", config={self.device: self.config}, wait_for_response=False
)
logger.info("Waiting for config reply")
reply = self.config_helper.wait_for_config_reply(RID, timeout=timeout)
self.config_helper.handle_update_reply(reply, RID, timeout)
logger.info("Done updating config!")
except Exception as e:
self.signals.error.emit(e)
finally:
self.signals.done.emit()
def _try_literal_eval(value: str):
if value == "":
return ""
try:
return literal_eval(value)
except SyntaxError as e:
raise ValueError(f"Entered config value {value} is not a valid python value!") from e
class DeviceConfigDialog(BECWidget, QDialog):
@@ -62,17 +45,31 @@ class DeviceConfigDialog(BECWidget, QDialog):
def __init__(
self,
*,
parent=None,
device: str | None = None,
config_helper: ConfigHelper | None = None,
action: Literal["update", "add"] = "update",
threadpool: QThreadPool | None = None,
**kwargs,
):
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
for device specification in bec_lib.atlas_models.
Args:
parent (QObject): the parent QObject
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
"""
self._initial_config = {}
super().__init__(parent=parent, **kwargs)
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name
)
self.threadpool = QThreadPool()
self._device = device
self._action = action
self._q_threadpool = threadpool or QThreadPool()
self.setWindowTitle(f"Edit config for: {device}")
self._container = QStackedLayout()
self._container.setStackingMode(QStackedLayout.StackAll)
@@ -85,13 +82,37 @@ class DeviceConfigDialog(BECWidget, QDialog):
user_warning.setWordWrap(True)
user_warning.setStyleSheet("QLabel { color: red; }")
self._layout.addWidget(user_warning)
self.get_bec_shortcuts()
self._add_form()
if self._action == "update":
self._form._validity.setVisible(False)
else:
self._set_schema_to_check_devices()
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
# self._form._validity.setVisible(True)
self._form.validity_proc.connect(self.enable_buttons_for_validity)
self._add_overlay()
self._add_buttons()
self.setLayout(self._container)
self._form.validate_form()
self._overlay_widget.setVisible(False)
def _set_schema_to_check_devices(self):
class _NameValidatedConfigModel(DeviceConfigModel):
@field_validator("name")
@staticmethod
def _validate_name(value: str, *_):
if not value.isidentifier():
raise ValueError(
f"Invalid device name: {value}. Device names must be valid Python identifiers."
)
if value in self.dev:
raise ValueError(f"A device with name {value} already exists!")
return value
self._form.set_schema(_NameValidatedConfigModel)
def _add_form(self):
self._form_widget = QWidget()
self._form_widget.setLayout(self._layout)
@@ -99,11 +120,15 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._layout.addWidget(self._form)
for row in self._form.enumerate_form_widgets():
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
if (
row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE
and self._action == "update"
):
row.widget._set_pretty_display()
self._fetch_config()
self._fill_form()
if self._action == "update" and self._device in self.dev:
self._fetch_config()
self._fill_form()
self._container.addWidget(self._form_widget)
def _add_overlay(self):
@@ -120,16 +145,15 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._container.addWidget(self._overlay_widget)
def _add_buttons(self):
button_box = QDialogButtonBox(
self.button_box = QDialogButtonBox(
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
self._layout.addWidget(button_box)
self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self._layout.addWidget(self.button_box)
def _fetch_config(self):
self._initial_config = {}
if (
self.client.device_manager is not None
and self._device in self.client.device_manager.devices
@@ -148,56 +172,70 @@ class DeviceConfigDialog(BECWidget, QDialog):
# TODO: special cased in some parts of device manager but not others, should
# be removed in config update as with below issue
diff["deviceConfig"].pop("device_access", None)
# TODO: replace when https://github.com/bec-project/bec/issues/528 is resolved
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
diff["deviceConfig"] = {
k: literal_eval(str(v)) for k, v in diff["deviceConfig"].items()
k: _try_literal_eval(str(v)) for k, v in diff["deviceConfig"].items() if k != ""
}
return diff
@SafeSlot()
@SafeSlot(bool)
def enable_buttons_for_validity(self, valid: bool):
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
for button in [
self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok]
]:
button.setEnabled(valid)
button.setToolTip(self._form._validity_message.text())
@SafeSlot(popup_error=True)
def apply(self):
self._process_update_action()
self._process_action()
self.applied.emit()
@SafeSlot()
@SafeSlot(popup_error=True)
def accept(self):
self._process_update_action()
self._process_action()
return super().accept()
def _process_update_action(self):
def _process_action(self):
updated_config = self.updated_config()
if (device_name := updated_config.get("name")) == "":
logger.warning("Can't create a device with no name!")
elif set(updated_config.keys()) & set(DEVICE_CONF_KEYS.NON_UPDATABLE):
logger.info(
f"Removing old device {self._device} and adding new device {device_name or self._device} with modified config: {updated_config}"
)
if self._action == "add":
if (name := updated_config.get("name")) in self.dev:
raise ValueError(
f"Can't create a new device with the same name as already existing device {name}!"
)
self._proc_device_config_change(updated_config)
else:
self._update_device_config(updated_config)
if updated_config == {}:
logger.info("No changes made to device config")
return
self._proc_device_config_change(updated_config)
def _update_device_config(self, config: dict):
if self._device is None:
return
if config == {}:
logger.info("No changes made to device config")
return
def _proc_device_config_change(self, config: dict):
logger.info(f"Sending request to update device config: {config}")
self._start_waiting_display()
communicate_update = _CommunicateUpdate(self._config_helper, self._device, config)
communicate_update = CommunicateConfigAction(
self._config_helper, self._device, config, self._action
)
communicate_update.signals.error.connect(self.update_error)
communicate_update.signals.done.connect(self.update_done)
self.threadpool.start(communicate_update)
self._q_threadpool.start(communicate_update)
@SafeSlot()
def update_done(self):
self._stop_waiting_display()
self._fetch_config()
self._fill_form()
if self._action == "update":
self._fetch_config()
self._fill_form()
@SafeSlot(Exception, popup_error=True)
def update_error(self, e: Exception):
raise RuntimeError("Failed to update device configuration") from e
self._stop_waiting_display()
if self._action == "update":
self._fetch_config()
self._fill_form()
raise e
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
@@ -238,7 +276,8 @@ def main(): # pragma: no cover
def _show_dialog(*_):
nonlocal dialog
if dialog is None:
dialog = DeviceConfigDialog(device=device.text())
kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
dialog = DeviceConfigDialog(**kwargs)
dialog.accepted.connect(accept)
dialog.rejected.connect(_destroy_dialog)
dialog.open()

View File

@@ -42,6 +42,7 @@ class DeviceConfigForm(PydanticModelForm):
if theme is None:
theme = get_theme_name()
self.setStyleSheet(styles.pretty_display_theme(theme))
self._validity.setVisible(False)
def get_form_data(self):
"""Get the entered metadata as a dict."""
@@ -54,7 +55,9 @@ class DeviceConfigForm(PydanticModelForm):
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
def set_schema(self, schema: type[BaseModel]):
raise TypeError("This class doesn't support changing the schema")
if not issubclass(schema, DeviceConfigModel):
raise TypeError("This class doesn't support changing the schema")
super().set_schema(schema)
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
super().set_data(data)

View File

@@ -3,15 +3,20 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import ConfigHelper
from bec_lib.devicemanager import DeviceContainer
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, QSize, Qt, Signal
from qtpy.QtCore import QMimeData, QSize, Qt, QThreadPool, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
)
@@ -31,10 +36,20 @@ logger = bec_logger.logger
class DeviceItem(ExpandableGroupFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
RPC = False
def __init__(self, parent, device: str, devices: DeviceContainer, icon: str = "") -> None:
def __init__(
self,
*,
parent,
device: str,
devices: DeviceContainer,
icon: str = "",
config_helper: ConfigHelper,
q_threadpool: QThreadPool | None = None,
) -> None:
super().__init__(parent, title=device, expanded=False, icon=icon)
self.dev = devices
self._drag_pos = None
@@ -48,35 +63,64 @@ class DeviceItem(ExpandableGroupFrame):
self._tab_widget.setDocumentMode(True)
self._layout.addWidget(self._tab_widget)
self.set_layout(self._layout)
self._form_page = QWidget()
self._form_page = QWidget(parent=self)
self._form_page_layout = QVBoxLayout()
self._form_page.setLayout(self._form_page_layout)
self._signal_page = QWidget()
self._signal_page = QWidget(parent=self)
self._signal_page_layout = QVBoxLayout()
self._signal_page.setLayout(self._signal_page_layout)
self._tab_widget.addTab(self._form_page, "Configuration")
self._tab_widget.addTab(self._signal_page, "Signals")
self._config_helper = config_helper
self._q_threadpool = q_threadpool or QThreadPool()
self.set_layout(self._layout)
self.adjustSize()
def _create_title_layout(self, title: str, icon: str):
super()._create_title_layout(title, icon)
self.edit_button = QToolButton()
self.edit_button.setIcon(
material_icon(icon_name="edit", size=(10, 10), convert_to_pixmap=False)
)
self.edit_button.setIcon(material_icon(icon_name="edit", size=(15, 15)))
self._title_layout.insertWidget(self._title_layout.count() - 1, self.edit_button)
self.edit_button.clicked.connect(self._create_edit_dialog)
self.delete_button = QToolButton()
self.delete_button.setIcon(material_icon(icon_name="delete", size=(15, 15)))
self._title_layout.insertWidget(self._title_layout.count() - 1, self.delete_button)
self.delete_button.clicked.connect(self._delete_device)
@SafeSlot()
def _create_edit_dialog(self):
dialog = DeviceConfigDialog(parent=self, device=self.device)
dialog = DeviceConfigDialog(
parent=self,
device=self.device,
config_helper=self._config_helper,
threadpool=self._q_threadpool,
)
dialog.accepted.connect(self._reload_config)
dialog.applied.connect(self._reload_config)
dialog.open()
@SafeSlot()
def _delete_device(self):
self.expanded = False
deleter = CommunicateConfigAction(self._config_helper, self.device, None, "remove")
deleter.signals.error.connect(self._deletion_error)
deleter.signals.done.connect(self._deletion_done)
self._q_threadpool.start(deleter)
@SafeSlot(Exception, popup_error=True)
def _deletion_error(self, e: Exception):
raise e
@SafeSlot()
def _deletion_done(self):
self.imminent_deletion.emit()
self.deleteLater()
@SafeSlot()
def switch_expanded_state(self):
if not self.expanded and not self._expanded_first_time:
@@ -96,6 +140,11 @@ class DeviceItem(ExpandableGroupFrame):
self.adjustSize()
self.broadcast_size_hint.emit(self.sizeHint())
@SafeSlot(str, dict)
def config_update(self, action: ConfigAction, content: dict) -> None:
if self.device in content:
self._reload_config()
@SafeSlot(popup_error=True)
def _reload_config(self, *_):
self.set_display_config(self.dev[self.device]._config)
@@ -157,7 +206,12 @@ if __name__ == "__main__": # pragma: no cover
"deviceTags": {"tag1", "tag2", "tag3"},
"userParameter": {"some_setting": "some_ value"},
}
item = DeviceItem(widget, "Device", {"Device": MagicMock(enabled=True, _config=mock_config)})
item = DeviceItem(
parent=widget,
device="Device",
devices={"Device": MagicMock(enabled=True, _config=mock_config)}, # type: ignore
config_helper=ConfigHelper(MagicMock()),
)
layout.addWidget(DarkModeButton())
layout.addWidget(item)
widget.show()

View File

@@ -0,0 +1,5 @@
from .scan_history_device_viewer import ScanHistoryDeviceViewer
from .scan_history_metadata_viewer import ScanHistoryMetadataViewer
from .scan_history_view import ScanHistoryView
__all__ = ["ScanHistoryDeviceViewer", "ScanHistoryMetadataViewer", "ScanHistoryView"]

View File

@@ -0,0 +1,232 @@
"""Module for displaying scan history devices in a viewer widget."""
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanHistoryMessage
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import _StoredDataInfo
logger = bec_logger.logger
class SignalModel(QtCore.QAbstractListModel):
"""Custom model for displaying scan history signals in a combo box."""
def __init__(self, parent=None, signals: dict[str, _StoredDataInfo] = None):
super().__init__(parent)
if signals is None:
signals = {}
self._signals: list[tuple[str, _StoredDataInfo]] = sorted(
signals.items(), key=lambda x: -x[1].shape[0]
)
@property
def signals(self) -> list[tuple[str, _StoredDataInfo]]:
"""Return the list of devices."""
return self._signals
@signals.setter
def signals(self, value: dict[str, _StoredDataInfo]):
self.beginResetModel()
self._signals = sorted(value.items(), key=lambda x: -x[1].shape[0])
self.endResetModel()
def rowCount(self, parent=QtCore.QModelIndex()):
return len(self._signals)
def data(self, index, role=QtCore.Qt.DisplayRole):
if not index.isValid():
return None
name, info = self.signals[index.row()]
if role == QtCore.Qt.DisplayRole:
return f"{name} {info.shape}" # fallback display
elif role == QtCore.Qt.UserRole:
return name
elif role == QtCore.Qt.UserRole + 1:
return info.shape
return None
# Custom delegate for better formatting
class SignalDelegate(QtWidgets.QStyledItemDelegate):
"""Custom delegate for displaying device names and points in the combo box."""
def paint(self, painter, option, index):
name = index.data(QtCore.Qt.UserRole)
points = index.data(QtCore.Qt.UserRole + 1)
painter.save()
painter.drawText(
option.rect.adjusted(5, 0, -5, 0), QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, name
)
painter.drawText(
option.rect.adjusted(5, 0, -5, 0),
QtCore.Qt.AlignVCenter | QtCore.Qt.AlignRight,
str(points),
)
painter.restore()
def sizeHint(self, option, index):
return QtCore.QSize(200, 24)
class ScanHistoryDeviceViewer(BECWidget, QtWidgets.QWidget):
"""ScanHistoryTree is a widget that displays the scan history in a tree format."""
RPC = False
PLUGIN = False
request_history_plot = QtCore.Signal(str, str, str) # (scan_id, device_name, signal_name)
def __init__(
self,
parent: QtWidgets.QWidget = None,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
theme_update: bool = True,
**kwargs,
):
super().__init__(
parent=parent,
client=client,
config=config,
gui_id=gui_id,
theme_update=theme_update,
**kwargs,
)
# Current scan history message
self.scan_history_msg: ScanHistoryMessage | None = None
self._last_device_name: str | None = None
self._last_signal_name: str | None = None
# Init layout
layout = QtWidgets.QVBoxLayout(self)
self.setLayout(layout)
# Init widgets
self.device_combo = QtWidgets.QComboBox(parent=self)
self.signal_combo = QtWidgets.QComboBox(parent=self)
colors = get_accent_colors()
self.request_plotting_button = QtWidgets.QPushButton(
material_icon("play_arrow", size=(24, 24), color=colors.success),
"Request Plotting",
self,
)
self.signal_model = SignalModel(parent=self.signal_combo)
self.signal_combo.setModel(self.signal_model)
self.signal_combo.setItemDelegate(SignalDelegate())
self._init_layout()
# Connect signals
self.request_plotting_button.clicked.connect(self._on_request_plotting_clicked)
self.device_combo.currentTextChanged.connect(self._signal_combo_update)
def _init_layout(self):
"""Initialize the layout for the device viewer."""
main_layout = self.layout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# horzizontal layout for device combo and signal combo boxes
widget = QtWidgets.QWidget(self)
hor_layout = QtWidgets.QHBoxLayout()
hor_layout.setContentsMargins(0, 0, 0, 0)
hor_layout.setSpacing(0)
widget.setLayout(hor_layout)
hor_layout.addWidget(self.device_combo)
hor_layout.addWidget(self.signal_combo)
main_layout.addWidget(widget)
main_layout.addWidget(self.request_plotting_button)
@SafeSlot(dict, dict)
def update_devices_from_scan_history(self, msg: dict, metadata: dict | None = None) -> None:
"""Update the device combo box with the scan history message.
Args:
msg (ScanHistoryMessage): The scan history message containing device data.
"""
msg = ScanHistoryMessage(**msg)
if metadata is not None:
msg.metadata = metadata
# Keep track of current device name
self._last_device_name = self.device_combo.currentText()
current_signal_index = self.signal_combo.currentIndex()
self._last_signal_name = self.signal_combo.model().data(
self.signal_combo.model().index(current_signal_index, 0), QtCore.Qt.UserRole
)
# Update the scan history message
self.scan_history_msg = msg
self.device_combo.clear()
self.device_combo.addItems(msg.stored_data_info.keys())
index = self.device_combo.findData(self._last_device_name, role=QtCore.Qt.DisplayRole)
if index != -1:
self.device_combo.setCurrentIndex(index)
@SafeSlot(str)
def _signal_combo_update(self, device_name: str) -> None:
"""Update the signal combo box based on the selected device."""
if not self.scan_history_msg:
logger.info("No scan history message available to update signals.")
return
if not device_name:
return
signal_data = self.scan_history_msg.stored_data_info.get(device_name, None)
if signal_data is None:
logger.info(f"No signal data found for device {device_name}.")
return
self.signal_model.signals = signal_data
if self._last_signal_name is not None:
# Try to restore the last selected signal
index = self.signal_combo.findData(self._last_signal_name, role=QtCore.Qt.UserRole)
if index != -1:
self.signal_combo.setCurrentIndex(index)
@SafeSlot()
def clear_view(self) -> None:
"""Clear the device combo box."""
self.scan_history_msg = None
self.signal_model.signals = {}
self.device_combo.clear()
@SafeSlot()
def _on_request_plotting_clicked(self):
"""Handle the request plotting button click."""
if self.scan_history_msg is None:
logger.info("No scan history message available for plotting.")
return
device_name = self.device_combo.currentText()
signal_index = self.signal_combo.currentIndex()
signal_name = self.signal_combo.model().data(
self.device_combo.model().index(signal_index, 0), QtCore.Qt.UserRole
)
logger.info(
f"Requesting plotting clicked: Scan ID:{self.scan_history_msg.scan_id}, device name: {device_name} with signal name: {signal_name}."
)
self.request_history_plot.emit(self.scan_history_msg.scan_id, device_name, signal_name)
if __name__ == "__main__": # pragma: no cover
import sys
app = QtWidgets.QApplication(sys.argv)
main_window = QtWidgets.QMainWindow()
central_widget = QtWidgets.QWidget()
main_window.setCentralWidget(central_widget)
ly = QtWidgets.QVBoxLayout(central_widget)
ly.setContentsMargins(0, 0, 0, 0)
viewer = ScanHistoryDeviceViewer()
ly.addWidget(viewer)
main_window.show()
app.exec_()
app.exec_()

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
from datetime import datetime
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanHistoryMessage
from bec_qthemes import material_icon
from qtpy import QtGui, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class ScanHistoryMetadataViewer(BECWidget, QtWidgets.QGroupBox):
"""ScanHistoryView is a widget to display the metadata of a ScanHistoryMessage in a structured format."""
RPC = False
PLUGIN = False
def __init__(
self,
parent: QtWidgets.QWidget | None = None,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
theme_update: bool = True,
scan_history_msg: ScanHistoryMessage | None = None,
):
"""
Initialize the ScanHistoryMetadataViewer widget.
Args:
parent (QtWidgets.QWidget, optional): The parent widget.
client: The BEC client.
config (ConnectionConfig, optional): The connection configuration.
gui_id (str, optional): The GUI ID.
theme_update (bool, optional): Whether to subscribe to theme updates. Defaults to True.
scan_history_msg (ScanHistoryMessage, optional): The scan history message to display. Defaults
"""
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=theme_update
)
self._scan_history_msg_labels = {
"scan_id": "Scan ID",
"dataset_number": "Dataset Nr",
"file_path": "File Path",
"start_time": "Start Time",
"end_time": "End Time",
"elapsed_time": "Elapsed Time",
"exit_status": "Status",
"scan_name": "Scan Name",
"num_points": "Nr of Points",
}
self.setTitle("No Scan Selected")
layout = QtWidgets.QGridLayout()
self.setLayout(layout)
self._init_grid_layout()
self.scan_history_msg = scan_history_msg
if scan_history_msg is not None:
self.update_view(self.scan_history_msg.content, self.scan_history_msg.metadata)
self.apply_theme()
def apply_theme(self, theme: str | None = None):
"""Apply the theme to the widget."""
colors = get_theme_palette()
palette = QtGui.QPalette()
palette.setColor(self.backgroundRole(), colors.midlight().color())
self.setPalette(palette)
def _init_grid_layout(self):
"""Initialize the layout of the widget."""
layout: QtWidgets.QGridLayout = self.layout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setHorizontalSpacing(0)
layout.setVerticalSpacing(0)
layout.setColumnStretch(0, 0)
layout.setColumnStretch(1, 1)
layout.setColumnStretch(2, 0)
def setup_content_widget_label(self) -> None:
"""Setup the labels for the content widget for the scan history view."""
layout = self.layout()
for row, k in enumerate(self._scan_history_msg_labels.keys()):
v = self._scan_history_msg_labels[k]
# Label for the key
label = QtWidgets.QLabel(f"{v}:")
layout.addWidget(label, row, 0)
# Value field
value_field = QtWidgets.QLabel("")
value_field.setSizePolicy(
QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Preferred
)
layout.addWidget(value_field, row, 1)
# Copy button
copy_button = QtWidgets.QToolButton()
copy_button.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum)
copy_button.setContentsMargins(0, 0, 0, 0)
copy_button.setStyleSheet("padding: 0px; margin: 0px; border: none;")
copy_button.setIcon(material_icon(icon_name="content_copy", size=(16, 16)))
copy_button.setToolTip("Copy to clipboard")
copy_button.setVisible(False)
copy_button.setEnabled(False)
copy_button.clicked.connect(
lambda _, field=value_field: QtWidgets.QApplication.clipboard().setText(
field.text()
)
)
layout.addWidget(copy_button, row, 2)
@SafeSlot(dict, dict)
def update_view(self, msg: dict, metadata: dict | None = None) -> None:
"""
Update the view with the given ScanHistoryMessage.
Args:
msg (ScanHistoryMessage): The message containing scan metadata.
"""
msg = ScanHistoryMessage(**msg)
if metadata is not None:
msg.metadata = metadata
if msg == self.scan_history_msg:
return
self.scan_history_msg = msg
layout = self.layout()
if layout.count() == 0:
self.setup_content_widget_label()
self.setTitle(f"Metadata - Scan {msg.scan_number}")
for row, k in enumerate(self._scan_history_msg_labels.keys()):
if k == "elapsed_time":
value = (
f"{(msg.end_time - msg.start_time):.3f}s"
if msg.start_time and msg.end_time
else None
)
else:
value = getattr(msg, k, None)
if k in ["start_time", "end_time"]:
value = (
datetime.fromtimestamp(value).strftime("%a %b %d %H:%M:%S %Y")
if value
else None
)
if value is None:
logger.warning(f"ScanHistoryMessage missing value for {k} and msg {msg}.")
continue
layout.itemAtPosition(row, 1).widget().setText(str(value))
if k in ["file_path", "scan_id"]: # Enable copy for file path and scan ID
layout.itemAtPosition(row, 2).widget().setVisible(True)
layout.itemAtPosition(row, 2).widget().setEnabled(True)
else:
layout.itemAtPosition(row, 2).widget().setText("")
layout.itemAtPosition(row, 2).widget().setToolTip("")
@SafeSlot()
def clear_view(self):
"""
Clear the view by resetting the labels and values.
"""
layout = self.layout()
lauout_counts = layout.count()
for i in range(lauout_counts):
item = layout.itemAt(i)
if item.widget():
item.widget().close()
item.widget().deleteLater()
self.scan_history_msg = None
self.setTitle("No Scan Selected")

View File

@@ -0,0 +1,261 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.callback_handler import EventType
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanHistoryMessage
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
if TYPE_CHECKING:
from bec_lib.client import BECClient
logger = bec_logger.logger
class BECHistoryManager(QtCore.QObject):
"""History manager for scan history operations. This class
is responsible for emitting signals when the scan history is updated.
"""
# ScanHistoryMessage.model_dump() (dict)
scan_history_updated = QtCore.Signal(dict)
def __init__(self, parent, client: BECClient):
super().__init__(parent)
self.client = client
self._cb_id = self.client.callbacks.register(
event_type=EventType.SCAN_HISTORY_UPDATE, callback=self._on_scan_history_update
)
def refresh_scan_history(self) -> None:
"""Refresh the scan history from the client."""
for scan_id in self.client.history._scan_ids: # pylint: disable=protected-access
history_msg = self.client.history._scan_data.get(scan_id, None)
if history_msg is None:
logger.info(f"Scan history message for scan_id {scan_id} not found.")
continue
self.scan_history_updated.emit(history_msg.model_dump())
def _on_scan_history_update(self, history_msg: ScanHistoryMessage) -> None:
"""Handle scan history updates from the client."""
self.scan_history_updated.emit(history_msg.model_dump())
def cleanup(self) -> None:
"""Clean up the manager by disconnecting callbacks."""
self.client.callbacks.remove(self._cb_id)
self.scan_history_updated.disconnect()
class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
"""ScanHistoryTree is a widget that displays the scan history in a tree format."""
RPC = False
PLUGIN = False
# ScanHistoryMessage.content, ScanHistoryMessage.metadata
scan_selected = QtCore.Signal(dict, dict)
no_scan_selected = QtCore.Signal()
def __init__(
self,
parent: QtWidgets.QWidget = None,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
max_length: int = 100,
theme_update: bool = True,
**kwargs,
):
super().__init__(
parent=parent,
client=client,
config=config,
gui_id=gui_id,
theme_update=theme_update,
**kwargs,
)
colors = get_accent_colors()
self.status_colors = {
"closed": colors.success,
"halted": colors.warning,
"aborted": colors.emergency,
}
# self.status_colors = {"closed": "#00e676", "halted": "#ffca28", "aborted": "#ff5252"}
self.column_header = ["Scan Nr", "Scan Name", "Status"]
self.scan_history: list[ScanHistoryMessage] = [] # newest at index 0
self.max_length = max_length # Maximum number of scan history entries to keep
self.bec_scan_history_manager = BECHistoryManager(parent=self, client=self.client)
self._set_policies()
self.apply_theme()
self.currentItemChanged.connect(self._current_item_changed)
header = self.header()
header.setToolTip(f"Last {self.max_length} scans in history.")
self.bec_scan_history_manager.scan_history_updated.connect(self.update_history)
self.refresh()
def _set_policies(self):
"""Set the policies for the tree widget."""
self.setColumnCount(len(self.column_header))
self.setHeaderLabels(self.column_header)
self.setRootIsDecorated(False) # allow expand arrow for perscan details
self.setUniformRowHeights(True)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setAlternatingRowColors(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.setIndentation(12)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setAnimated(True)
header = self.header()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
for column in range(1, self.columnCount()):
header.setSectionResizeMode(column, QtWidgets.QHeaderView.ResizeMode.Stretch)
def apply_theme(self, theme: str | None = None):
"""Apply the theme to the widget."""
colors = get_accent_colors()
self.status_colors = {
"closed": colors.success,
"halted": colors.warning,
"aborted": colors.emergency,
}
self.repaint()
def _current_item_changed(
self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem
):
"""
Handle current item change events in the tree widget.
Args:
current (QtWidgets.QTreeWidgetItem): The currently selected item.
previous (QtWidgets.QTreeWidgetItem): The previously selected item.
"""
if not current:
return
index = self.indexOfTopLevelItem(current)
self.scan_selected.emit(self.scan_history[index].content, self.scan_history[index].metadata)
@SafeSlot()
def refresh(self):
"""Refresh the scan history view."""
while len(self.scan_history) > 0:
self.remove_scan(index=0)
self.bec_scan_history_manager.refresh_scan_history()
@SafeSlot(dict)
def update_history(self, msg_dump: dict):
"""Update the scan history with new scan data."""
msg = ScanHistoryMessage(**msg_dump)
self.add_scan(msg)
self.ensure_history_max_length()
def ensure_history_max_length(self) -> None:
"""
Method to ensure the scan history does not exceed the maximum length.
If the length exceeds the maximum, it removes the oldest entry.
This is called after adding a new scan to the history.
"""
while len(self.scan_history) > self.max_length:
logger.warning(
f"Removing oldest scan history entry to maintain max length of {self.max_length}."
)
self.remove_scan(index=-1)
def add_scan(self, msg: ScanHistoryMessage):
"""
Add a scan entry to the tree widget.
Args:
msg (ScanHistoryMessage): The scan history message containing scan details.
"""
if msg.stored_data_info is None:
logger.info(
f"Old scan history entry fo scan {msg.scan_id} without stored_data_info, skipping."
)
return
if msg in self.scan_history:
logger.info(f"Scan {msg.scan_id} already in history, skipping.")
return
self.scan_history.insert(0, msg)
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
color = QtGui.QColor(self.status_colors.get(msg.exit_status, "#b0bec5"))
pix = QtGui.QPixmap(10, 10)
pix.fill(QtCore.Qt.transparent)
with QtGui.QPainter(pix) as p:
p.setRenderHint(QtGui.QPainter.Antialiasing)
p.setPen(QtCore.Qt.NoPen)
p.setBrush(color)
p.drawEllipse(0, 0, 10, 10)
tree_item.setIcon(2, QtGui.QIcon(pix))
tree_item.setForeground(2, QtGui.QBrush(color))
for col in range(tree_item.columnCount()):
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
self.insertTopLevelItem(0, tree_item)
tree_item.setExpanded(False)
def remove_scan(self, index: int):
"""
Remove a scan entry from the tree widget.
We supoprt negative indexing where -1, -2, etc.
Args:
index (int): The index of the scan entry to remove.
"""
if index < 0:
index = len(self.scan_history) + index
try:
msg = self.scan_history.pop(index)
self.no_scan_selected.emit()
except IndexError:
logger.warning(f"Invalid index {index} for removing scan entry from history.")
return
self.takeTopLevelItem(index)
def cleanup(self):
"""Cleanup the widget"""
self.bec_scan_history_manager.cleanup()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
ScanHistoryMetadataViewer,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QtWidgets.QApplication([])
main_window = QtWidgets.QMainWindow()
central_widget = QtWidgets.QWidget()
button = DarkModeButton()
layout = QtWidgets.QVBoxLayout(central_widget)
main_window.setCentralWidget(central_widget)
# Create a ScanHistoryBrowser instance
browser = ScanHistoryView()
# Create a ScanHistoryView instance
view = ScanHistoryMetadataViewer()
device_viewer = ScanHistoryDeviceViewer()
layout.addWidget(button)
layout.addWidget(browser)
layout.addWidget(view)
layout.addWidget(device_viewer)
browser.scan_selected.connect(view.update_view)
browser.scan_selected.connect(device_viewer.update_devices_from_scan_history)
browser.no_scan_selected.connect(view.clear_view)
browser.no_scan_selected.connect(device_viewer.clear_view)
main_window.show()
app.exec_()

View File

@@ -0,0 +1,115 @@
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
ScanHistoryMetadataViewer,
ScanHistoryView,
)
class ScanHistoryBrowser(BECWidget, QtWidgets.QWidget):
"""
ScanHistoryBrowser is a widget combining the scan history view, metadata viewer, and device viewer.
Target is to provide a popup view for the Waveform Widget to browse the scan history.
"""
RPC = False
PLUGIN = False
def __init__(
self,
parent: QtWidgets.QWidget | None = None,
client=None,
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
**kwargs,
):
"""
Initialize the ScanHistoryBrowser widget.
Args:
parent (QtWidgets.QWidget, optional): The parent widget.
client: The BEC client.
config (ConnectionConfig, optional): The connection configuration.
gui_id (str, optional): The GUI ID.
theme_update (bool, optional): Whether to subscribe to theme updates. Defaults to False.
"""
super().__init__(
parent=parent,
client=client,
config=config,
gui_id=gui_id,
theme_update=theme_update,
**kwargs,
)
layout = QtWidgets.QHBoxLayout()
self.setLayout(layout)
self.scan_history_view = ScanHistoryView(
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
)
self.scan_history_metadata_viewer = ScanHistoryMetadataViewer(
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
)
self.scan_history_device_viewer = ScanHistoryDeviceViewer(
parent=self, client=client, config=config, gui_id=gui_id, theme_update=theme_update
)
self.init_layout()
self.connect_signals()
def init_layout(self):
"""Initialize compact layout for the widget."""
# Add Scan history view
layout: QtWidgets.QHBoxLayout = self.layout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.scan_history_view)
# Add metadata and device viewers in a vertical layout
widget = QtWidgets.QWidget(self)
vertical_layout = QtWidgets.QVBoxLayout()
vertical_layout.setContentsMargins(0, 0, 0, 0)
vertical_layout.setSpacing(0)
vertical_layout.addWidget(self.scan_history_metadata_viewer)
vertical_layout.addWidget(self.scan_history_device_viewer)
widget.setLayout(vertical_layout)
# Add the vertical layout widget to the main layout
layout.addWidget(widget)
def connect_signals(self):
"""Connect signals from scan history components."""
self.scan_history_view.scan_selected.connect(self.scan_history_metadata_viewer.update_view)
self.scan_history_view.scan_selected.connect(
self.scan_history_device_viewer.update_devices_from_scan_history
)
self.scan_history_view.no_scan_selected.connect(
self.scan_history_metadata_viewer.clear_view
)
self.scan_history_view.no_scan_selected.connect(self.scan_history_device_viewer.clear_view)
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication([])
main_window = QtWidgets.QMainWindow()
central_widget = QtWidgets.QWidget()
button = DarkModeButton()
layout = QtWidgets.QVBoxLayout(central_widget)
main_window.setCentralWidget(central_widget)
# Create a ScanHistoryBrowser instance
browser = ScanHistoryBrowser() # type: ignore
layout.addWidget(button)
layout.addWidget(browser)
main_window.setWindowTitle("Scan History Browser")
main_window.resize(800, 400)
main_window.show()
app.exec_()

View File

@@ -10,7 +10,6 @@ from bec_qthemes import material_icon
from qtpy.QtCore import Signal as QSignal
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QGroupBox,
@@ -121,7 +120,9 @@ class ChoiceDialog(QDialog):
self._signal_field.clear()
def accept(self):
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
self.accepted_output.emit(
self._device_field.text(), self._signal_field.selected_signal_comp_name
)
return super().accept()

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt, QTimer
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QPushButton,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeProperty
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class WidgetFinderComboBox(QComboBox):
def __init__(self, parent=None, widget_class: type[QWidget] | str | None = None):
super().__init__(parent)
self.widget_class = widget_class
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
self.setMinimumWidth(200)
# find button inside combobox
self.find_button = QToolButton(self)
self.find_button.setIcon(material_icon("frame_inspect"))
self.find_button.setCursor(Qt.PointingHandCursor)
self.find_button.setFocusPolicy(Qt.NoFocus)
self.find_button.setToolTip("Highlight selected widget")
self.find_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.find_button.clicked.connect(self.inspect_widget)
# refresh button inside combobox
self.refresh_button = QToolButton(self)
self.refresh_button.setIcon(material_icon("refresh"))
self.refresh_button.setCursor(Qt.PointingHandCursor)
self.refresh_button.setFocusPolicy(Qt.NoFocus)
self.refresh_button.setToolTip("Refresh widget list")
self.refresh_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.refresh_button.clicked.connect(self.refresh_list)
# Purple Highlighter
self.highlighter = None
# refresh items - delay to fetch widgets after UI is ready in next event loop
QTimer.singleShot(0, self.refresh_list)
def _init_highlighter(self):
"""
Initialize the highlighter frame that will be used to highlight the inspected widget.
"""
self.highlighter = QFrame(self, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.highlighter.setAttribute(Qt.WA_TransparentForMouseEvents)
self.highlighter.setStyleSheet(
"border: 2px solid #FF00FF; border-radius: 6px; background: transparent;"
)
def resizeEvent(self, event):
super().resizeEvent(event)
btn_size = 16
arrow_width = 24
x = self.width() - arrow_width - btn_size - 2
y = (self.height() - btn_size) // 2 - 2
# position find_button first
self.find_button.setFixedSize(btn_size, btn_size)
self.find_button.move(x, y)
# position refresh_button to the left of find_button
refresh_x = x - btn_size - 2
self.refresh_button.setFixedSize(btn_size, btn_size)
self.refresh_button.move(refresh_x, y)
def refresh_list(self):
"""
Refresh the list of widgets in the combobox based on the specified widget class.
"""
self.clear()
if self.widget_class is None:
return
widgets = WidgetIO.find_widgets(self.widget_class, recursive=True)
# Build display names with counts for duplicates
name_counts: dict[str, int] = {}
for w in widgets:
base_name = w.objectName() or w.__class__.__name__
count = name_counts.get(base_name, 0) + 1
name_counts[base_name] = count
display_name = base_name if count == 1 else f"{base_name} ({count})"
self.addItem(display_name, w)
def showPopup(self):
"""
Refresh list each time the popup opens to reflect dynamic widget changes.
"""
self.refresh_list()
super().showPopup()
def inspect_widget(self):
"""
Inspect the currently selected widget in the combobox.
"""
target = self.currentData()
if not target:
return
# ensure highlighter exists, avoid calling methods on deleted C++ object
if not getattr(self, "highlighter", None):
self._init_highlighter()
else:
self.highlighter.hide()
# draw new
geom = target.frameGeometry()
pos = target.mapToGlobal(target.rect().topLeft())
self.highlighter.setGeometry(pos.x(), pos.y(), geom.width(), geom.height())
self.highlighter.show()
# Pulse and fade animation to draw attention
start_rect = QRect(pos.x() - 5, pos.y() - 5, geom.width() + 10, geom.height() + 10)
pulse = QPropertyAnimation(self.highlighter, b"geometry")
pulse.setDuration(300)
pulse.setStartValue(start_rect)
pulse.setEndValue(QRect(pos.x(), pos.y(), geom.width(), geom.height()))
fade = QPropertyAnimation(self.highlighter, b"windowOpacity")
fade.setDuration(2000)
fade.setStartValue(1.0)
fade.setEndValue(0.0)
fade.finished.connect(self.highlighter.hide)
group = QSequentialAnimationGroup(self)
group.addAnimation(pulse)
group.addAnimation(fade)
group.start()
@SafeProperty(str)
def widget_class_name(self) -> str:
"""
Get or set the target widget class by name.
"""
return (
self.widget_class if isinstance(self.widget_class, str) else self.widget_class.__name__
)
@widget_class_name.setter
def widget_class_name(self, name: str):
self.widget_class = name
self.refresh_list()
@property
def selected_widget(self):
"""
The currently selected QWidget instance (or None if not found).
"""
try:
return self.currentData()
except Exception:
return None
def cleanup(self):
"""
Clean up the highlighter frame when the combobox is deleted.
"""
if self.highlighter:
self.highlighter.close()
self.highlighter.deleteLater()
self.highlighter = None
def closeEvent(self, event):
"""
Override closeEvent to clean up the highlighter frame.
"""
self.cleanup()
event.accept()
class InspectorMainWindow(BECMainWindow): # pragma: no cover
"""
A main window that includes a widget finder combobox to inspect widgets.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Widget Inspector")
self.setMinimumSize(800, 600)
self.central_widget = QWidget(self)
self.setCentralWidget(self.central_widget)
self.central_widget.layout = QGridLayout(self.central_widget)
# Inspector box
self.group_box_inspector = QGroupBox(self.central_widget)
self.group_box_inspector.setTitle("Inspector")
self.group_box_inspector.layout = QVBoxLayout(self.group_box_inspector)
self.inspector_combobox = WidgetFinderComboBox(self.group_box_inspector, Waveform)
self.switch_combobox = QComboBox(self.group_box_inspector)
self.switch_combobox.addItems(["Waveform", "Image", "QPushButton"])
self.switch_combobox.setToolTip("Switch the widget class to inspect")
self.switch_combobox.currentTextChanged.connect(
lambda text: setattr(self.inspector_combobox, "widget_class_name", text)
)
self.group_box_inspector.layout.addWidget(self.inspector_combobox)
self.group_box_inspector.layout.addWidget(self.switch_combobox)
# Some bec widgets to inspect
self.wf1 = Waveform(self.central_widget)
self.wf2 = Waveform(self.central_widget)
self.im1 = Image(self.central_widget)
self.im2 = Image(self.central_widget)
# Some normal widgets to inspect
self.group_box_widgets = QGroupBox(self.central_widget)
self.group_box_widgets.setTitle("Widgets ")
self.group_box_widgets.layout = QVBoxLayout(self.group_box_widgets)
self.btn1 = QPushButton("Button 1", self.group_box_widgets)
self.btn1.setObjectName("btn1")
self.btn2 = QPushButton("Button 2", self.group_box_widgets)
self.btn2.setObjectName("btn1") # Same object name to test duplicate handling
self.btn3 = QPushButton("Button 3", self.group_box_widgets)
self.btn3.setObjectName("btn3")
self.group_box_widgets.layout.addWidget(self.btn1)
self.group_box_widgets.layout.addWidget(self.btn2)
self.group_box_widgets.layout.addWidget(self.btn3)
self.central_widget.layout.addWidget(self.group_box_inspector, 0, 0)
self.central_widget.layout.addWidget(self.group_box_widgets, 1, 0)
self.central_widget.layout.addWidget(self.wf1, 0, 1)
self.central_widget.layout.addWidget(self.wf2, 1, 1)
self.central_widget.layout.addWidget(self.im1, 0, 2)
self.central_widget.layout.addWidget(self.im2, 1, 2)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
main_window = InspectorMainWindow()
main_window.show()
sys.exit(app.exec())

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

View File

@@ -0,0 +1,106 @@
(user.widgets.heatmap_widget)=
# Heatmap widget
````{tab} Overview
The Heatmap widget is a specialized plotting tool designed for visualizing 2D grid data with color mapping for the z-axis. It excels at displaying data from grid scans or arbitrary step scans, automatically interpolating scattered data points into a coherent 2D image. Directly integrated with the `BEC` framework, it can display live data streams from scanning experiments within the current `BEC` session.
## Key Features:
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
- **Live Grid Scan Visualization**: Real-time plotting of grid scan data with automatic positioning and color mapping based on scan parameters.
- **Dual Scan Support**: Handles both structured grid scans (with pre-allocated grids) and unstructured step scans (with interpolation).
- **Intelligent Data Interpolation**: For arbitrary step scans, the widget automatically interpolates scattered (x, y, z) data points into a smooth 2D heatmap using various interpolation methods.
- **Oversampling**: Supports oversampling to enhance the appearance of the heatmap, allowing for smoother transitions and better visual representation of data. Especially useful the for nearest-neighbor interpolation.
- **Customizable Color Maps**: Wide variety of color maps available for data visualization, with support for both simple and full color bars.
- **Real-time Image Processing**: Apply real-time processing techniques such as FFT and logarithmic scaling to enhance data visualization.
- **Interactive Controls**: Comprehensive toolbar with settings for heatmap configuration, crosshair tools, mouse interaction, and data export capabilities.
```{figure} ./heatmap_grid_scan.gif
:width: 60%
Real-time heatmap visualization of a 2D grid scan showing motor positions and detector intensity
```
```{figure} ./heatmap_fermat_scan.gif
:width: 80%
Real-time heatmap visualization of an (not path-optimized) scan following Fermat's spiral pattern. On the left, the heatmap widget is shown with the oversampling option set to 10 and the interpolation method set to nearest neighbor. On the right, the scatter waveform widget is shown with the same data.
```
````
````{tab} Examples - CLI
`HeatmapWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
## Example 1 - Visualizing Grid Scan Data
In this example, we demonstrate how to add a `HeatmapWidget` to visualize live data from a 2D grid scan with motor positions and detector readout.
```python
# Add a new dock with HeatmapWidget
dock_area = gui.new()
heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
# Plot a heatmap with x and y motor positions and z detector signal
heatmap_widget.plot(
x_name='samx', # X-axis motor
y_name='samy', # Y-axis motor
z_name='bpm4i', # Z-axis detector signal
color_map='plasma'
)
heatmap_widget.title = "Grid Scan - Sample Position vs BPM Intensity"
```
## Example 2 - Step Scan with Custom Entries
This example shows how to visualize data from an arbitrary step scan by specifying custom data entries for each axis.
```python
# Add a new dock with HeatmapWidget
dock_area = gui.new()
heatmap_widget = dock_area.new().new(gui.available_widgets.Heatmap)
# Plot heatmap with specific data entries
heatmap_widget.plot(
x_name='motor1',
y_name='motor2',
z_name='detector1',
x_entry='RBV', # Use readback value for x
y_entry='RBV', # Use readback value for y
z_entry='value', # Use main value for z
color_map='viridis',
reload=True # Force reload of data
)
```
## Example 3 - Real-time Processing and Customization
The `Heatmap` widget provides real-time processing capabilities and extensive customization options for enhanced data visualization.
```python
# Configure heatmap appearance and processing
heatmap_widget.color_map = 'plasma'
heatmap_widget.lock_aspect_ratio = True
# Apply real-time processing
heatmap_widget.fft = True # Apply FFT to the data
heatmap_widget.log = True # Use logarithmic scaling
# Configure color bar and range
heatmap_widget.enable_full_colorbar = True
heatmap_widget.v_min = 0
heatmap_widget.v_max = 1000
```
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.rst
```
````

View File

@@ -61,6 +61,14 @@ Display a 1D waveforms with a third device on the z-axis.
Display signal from 2D detector.
```
```{grid-item-card} Heatmap Widget
:link: user.widgets.heatmap_widget
:link-type: ref
:img-top: /assets/widget_screenshots/heatmap_widget.png
Display 2D grid data with color mapping.
```
```{grid-item-card} Motor Map Widget
:link: user.widgets.motor_map
:link-type: ref
@@ -275,6 +283,7 @@ waveform/waveform_widget.md
scatter_waveform/scatter_waveform.md
multi_waveform/multi_waveform.md
image/image_widget.md
heatmap/heatmap_widget.md
motor_map/motor_map.md
scan_control/scan_control.md
progress_bar/ring_progress_bar.md

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.21.4"
version = "2.27.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -23,6 +23,7 @@ dependencies = [
"PySide6~=6.8.2",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"qtmonaco>=0.2.3",
]

View File

@@ -1,6 +1,10 @@
from unittest import mock
import json
import time
import h5py
import numpy as np
import pytest
from bec_lib import messages
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtWidgets import QApplication
@@ -83,3 +87,110 @@ def create_widget(qtbot, widget, *args, **kwargs):
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanHistoryMessage:
"""
Helper to create a history file with the given data.
The data should contain readout groups, e.g.
{
"baseline": {"samx": {"samx": {"value": [1, 2, 3], "timestamp": [100, 200, 300]}},
"monitored": {"bpm4i": {"bpm4i": {"value": [5, 6, 7], "timestamp": [101, 201, 301]}}},
"async": {"async_device": {"async_device": {"value": [1, 2, 3], "timestamp": [11, 21, 31]}}},
}
"""
with h5py.File(file_path, "w") as f:
_metadata = f.create_group("entry/collection/metadata")
_metadata.create_dataset("sample_name", data="test_sample")
metadata_bec = f.create_group("entry/collection/metadata/bec")
for key, value in metadata.items():
if isinstance(value, dict):
metadata_bec.create_group(key)
for sub_key, sub_value in value.items():
if isinstance(sub_value, list):
sub_value = json.dumps(sub_value)
metadata_bec[key].create_dataset(sub_key, data=sub_value)
elif isinstance(sub_value, dict):
for sub_sub_key, sub_sub_value in sub_value.items():
sub_sub_group = metadata_bec[key].create_group(sub_key)
if isinstance(sub_sub_value, list):
sub_sub_value = json.dumps(sub_sub_value)
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
else:
metadata_bec[key].create_dataset(sub_key, data=sub_value)
else:
metadata_bec.create_dataset(key, data=value)
for group, devices in data.items():
readout_group = f.create_group(f"entry/collection/readout_groups/{group}")
for device, device_data in devices.items():
dev_group = f.create_group(f"entry/collection/devices/{device}")
for signal, signal_data in device_data.items():
signal_group = dev_group.create_group(signal)
for signal_key, signal_values in signal_data.items():
signal_group.create_dataset(signal_key, data=signal_values)
readout_group[device] = h5py.SoftLink(f"/entry/collection/devices/{device}")
msg = messages.ScanHistoryMessage(
scan_id=metadata["scan_id"],
scan_name=metadata["scan_name"],
exit_status=metadata["exit_status"],
file_path=file_path,
scan_number=metadata["scan_number"],
dataset_number=metadata["dataset_number"],
start_time=time.time(),
end_time=time.time(),
num_points=metadata["num_points"],
request_inputs=metadata["request_inputs"],
)
return msg
@pytest.fixture
def grid_scan_history_msg(tmpdir):
x_grid, y_grid = np.meshgrid(np.linspace(-5, 5, 10), np.linspace(-5, 5, 10))
x_flat = x_grid.T.ravel()
y_flat = y_grid.T.ravel()
positions = np.vstack((x_flat, y_flat)).T
num_points = len(positions)
data = {
"baseline": {"bpm1a": {"bpm1a": {"value": [1], "timestamp": [100]}}},
"monitored": {
"bpm4i": {
"bpm4i": {
"value": np.random.rand(num_points),
"timestamp": np.random.rand(num_points),
}
},
"samx": {"samx": {"value": x_flat, "timestamp": np.random.rand(num_points)}},
"samy": {"samy": {"value": y_flat, "timestamp": np.random.rand(num_points)}},
},
"async": {
"async_device": {
"async_device": {
"value": np.random.rand(num_points * 10),
"timestamp": np.random.rand(num_points * 10),
}
}
},
}
metadata = {
"scan_id": "test_scan",
"scan_name": "grid_scan",
"scan_type": "step",
"exit_status": "closed",
"scan_number": 1,
"dataset_number": 1,
"request_inputs": {
"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10],
"kwargs": {"relative": True},
},
"positions": positions.tolist(),
"num_points": num_points,
}
file_path = str(tmpdir.join("scan_1.h5"))
return create_history_file(file_path, data, metadata)

View File

@@ -0,0 +1,28 @@
from unittest.mock import ANY, MagicMock
from bec_lib.config_helper import ConfigHelper
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
def test_must_have_a_name(qtbot):
error_occurred = False
def oops():
nonlocal error_occurred
error_occurred = True
c = CommunicateConfigAction(ConfigHelper(MagicMock()), device=None, config={}, action="add")
c.signals.error.connect(oops)
c.run()
qtbot.waitUntil(lambda: error_occurred, timeout=100)
def test_wait_for_reply_on_RID():
ch = MagicMock(spec=ConfigHelper)
ch.send_config_request.return_value = "abcde"
cca = CommunicateConfigAction(config_helper=ch, device="samx", config={}, action="update")
cca.run()
ch.wait_for_config_reply.assert_called_with("abcde", timeout=ANY)

View File

@@ -2,8 +2,10 @@ import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF, Qt
from qtpy.QtGui import QTransform
from bec_widgets.utils import Crosshair
from bec_widgets.widgets.plots.image.image_item import ImageItem
# pylint: disable = redefined-outer-name
@@ -27,7 +29,7 @@ def image_widget_with_crosshair(qtbot):
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
image_item = pg.ImageItem()
image_item = ImageItem()
image_item.setImage(np.random.rand(100, 100))
widget.addItem(image_item)
@@ -113,7 +115,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair.mouse_moved(event_mock)
assert emitted_values_2D == [(str(id(image_item)), 21, 55)]
assert emitted_values_2D == [("ImageItem", 21, 55)]
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
@@ -311,3 +313,53 @@ def test_crosshair_precision_properties_image(image_widget_with_crosshair):
crosshair.precision = 2
assert crosshair._current_precision() == 2
def test_get_transformed_position(plot_widget_with_crosshair):
"""Test that _get_transformed_position correctly transforms coordinates."""
crosshair, _ = plot_widget_with_crosshair
# Create a simple transform
transform = QTransform()
transform.translate(10, 20) # Origin is now at (10, 20)
# Test coordinates
x, y = 5, 8
# Get the transformed position
row, col = crosshair._get_transformed_position(x, y, transform)
# Calculate expected values:
# row should be the y-offset from origin after transform
# col should be the x-offset from origin after transform
expected_row = QPointF(0, 8) # y direction offset
expected_col = QPointF(5, 0) # x direction offset
# Check that the results match expectations
assert row == expected_row
assert col == expected_col
def test_get_transformed_position_with_scale(plot_widget_with_crosshair):
"""Test that _get_transformed_position correctly handles scaling transformations."""
crosshair, _ = plot_widget_with_crosshair
# Create a transform with scaling
transform = QTransform()
transform.translate(10, 20) # Origin is now at (10, 20)
transform.scale(2, 3) # Scale x by 2 and y by 3
# Test coordinates
x, y = 5, 8
# Get the transformed position
row, col = crosshair._get_transformed_position(x, y, transform)
# Calculate expected values with scaling applied:
# For a scale transform, the offsets should be multiplied by the scale factors
expected_row = QPointF(0, 8 * 3) # y direction offset with scale factor 3
expected_col = QPointF(5 * 2, 0) # x direction offset with scale factor 2
# Check that the results match expectations
assert row == expected_row
assert col == expected_col

View File

@@ -132,3 +132,13 @@ def test_device_item_double_click_event(device_browser, qtbot):
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
qtbot.mouseDClick(widget, Qt.LeftButton)
def test_device_deletion(device_browser, qtbot):
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
widget._config_helper = mock.MagicMock()
assert widget.device in device_browser._device_items
qtbot.mouseClick(widget.delete_button, Qt.LeftButton)
qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000)

View File

@@ -2,9 +2,12 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_lib.atlas_models import Device as DeviceConfigModel
from qtpy.QtWidgets import QDialogButtonBox, QPushButton
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
_try_literal_eval,
)
_BASIC_CONFIG = {
@@ -16,83 +19,123 @@ _BASIC_CONFIG = {
@pytest.fixture
def dialog(qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
def mock_client():
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
mock_client = MagicMock()
mock_client.device_manager.devices = {"test_device": mock_device}
dialog = DeviceConfigDialog(device="test_device", config_helper=MagicMock(), client=mock_client)
qtbot.addWidget(dialog)
return dialog
return mock_client
def test_initialization(dialog):
assert dialog._device == "test_device"
assert dialog._container.count() == 2
@pytest.fixture
def update_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
update_dialog = DeviceConfigDialog(
device="test_device", config_helper=MagicMock(), client=mock_client
)
qtbot.addWidget(update_dialog)
return update_dialog
def test_fill_form(dialog):
with patch.object(dialog._form, "set_data") as mock_set_data:
dialog._fill_form()
@pytest.fixture
def add_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
add_dialog = DeviceConfigDialog(
device=None, config_helper=MagicMock(), client=mock_client, action="add"
)
qtbot.addWidget(add_dialog)
return add_dialog
def test_initialization(update_dialog):
assert update_dialog._device == "test_device"
assert update_dialog._container.count() == 2
def test_fill_form(update_dialog):
with patch.object(update_dialog._form, "set_data") as mock_set_data:
update_dialog._fill_form()
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
def test_updated_config(dialog):
def test_updated_config(update_dialog):
"""Test that updated_config returns the correct changes."""
dialog._initial_config = {"key1": "value1", "key2": "value2"}
update_dialog._initial_config = {"key1": "value1", "key2": "value2"}
with patch.object(
dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
update_dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
):
updated = dialog.updated_config()
updated = update_dialog.updated_config()
assert updated == {"key2": "new_value"}
def test_apply(dialog):
with patch.object(dialog, "_process_update_action") as mock_process_update:
dialog.apply()
def test_apply(update_dialog):
with patch.object(update_dialog, "_process_action") as mock_process_update:
update_dialog.apply()
mock_process_update.assert_called_once()
def test_accept(dialog):
def test_accept(update_dialog):
with (
patch.object(dialog, "_process_update_action") as mock_process_update,
patch.object(update_dialog, "_process_action") as mock_process_update,
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
):
dialog.accept()
update_dialog.accept()
mock_process_update.assert_called_once()
mock_parent_accept.assert_called_once()
def test_waiting_display(dialog, qtbot):
def test_waiting_display(update_dialog, qtbot):
with (
patch.object(dialog._spinner, "start") as mock_spinner_start,
patch.object(dialog._spinner, "stop") as mock_spinner_stop,
patch.object(update_dialog._spinner, "start") as mock_spinner_start,
patch.object(update_dialog._spinner, "stop") as mock_spinner_stop,
):
dialog.show()
dialog._start_waiting_display()
qtbot.waitUntil(dialog._overlay_widget.isVisible, timeout=100)
update_dialog.show()
update_dialog._start_waiting_display()
qtbot.waitUntil(update_dialog._overlay_widget.isVisible, timeout=100)
mock_spinner_start.assert_called_once()
mock_spinner_stop.assert_not_called()
dialog._stop_waiting_display()
qtbot.waitUntil(lambda: not dialog._overlay_widget.isVisible(), timeout=100)
update_dialog._stop_waiting_display()
qtbot.waitUntil(lambda: not update_dialog._overlay_widget.isVisible(), timeout=100)
mock_spinner_stop.assert_called_once()
def test_update_cycle(dialog, qtbot):
def test_update_cycle(update_dialog, qtbot):
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
update_dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
for item in dialog._form.enumerate_form_widgets():
update_dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
for item in update_dialog._form.enumerate_form_widgets():
if (val := update.get(item.label.property("_model_field_name"))) is not None:
item.widget.setValue(val)
assert dialog.updated_config() == update
dialog.apply()
qtbot.waitUntil(lambda: dialog._config_helper.send_config_request.call_count == 1, timeout=100)
assert update_dialog.updated_config() == update
update_dialog.apply()
qtbot.waitUntil(
lambda: update_dialog._config_helper.send_config_request.call_count == 1, timeout=100
)
dialog._config_helper.send_config_request.assert_called_with(
update_dialog._config_helper.send_config_request.assert_called_with(
action="update", config={"test_device": update}, wait_for_response=False
)
def test_add_form_init_without_name(add_dialog, qtbot):
assert (name_widget := add_dialog._form.widget_dict.get("name")) is not None
assert isinstance(name_widget, StrFormItem)
assert name_widget.getValue() is None
def test_add_form_validates_and_disables_on_init(add_dialog, qtbot):
assert (ok_button := add_dialog.button_box.button(QDialogButtonBox.Ok)) is not None
assert isinstance(ok_button, QPushButton)
assert not ok_button.isEnabled()
def test_try_literal_eval():
assert _try_literal_eval("") == ""
assert _try_literal_eval("[1, 2, 3]") == [1, 2, 3]
assert _try_literal_eval('"[,,]"') == "[,,]"
with pytest.raises(ValueError) as e:
_try_literal_eval("[,,]")
assert e.match("Entered config value [,,]")

View File

@@ -0,0 +1,366 @@
from unittest import mock
import numpy as np
import pytest
from bec_lib import messages
from bec_lib.scan_history import ScanHistory
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
# pytest: disable=unused-import
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import create_dummy_scan_item
@pytest.fixture
def heatmap_widget(qtbot, mocked_client):
widget = Heatmap(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_heatmap_plot(heatmap_widget):
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
assert heatmap_widget._image_config.x_device.name == "samx"
assert heatmap_widget._image_config.y_device.name == "samy"
assert heatmap_widget._image_config.z_device.name == "bpm4i"
def test_heatmap_on_scan_status_no_scan_id(heatmap_widget):
scan_msg = messages.ScanStatusMessage(scan_id=None, status="open", metadata={}, info={})
with mock.patch.object(heatmap_widget, "reset") as mock_reset:
heatmap_widget.on_scan_status(scan_msg.content, scan_msg.metadata)
mock_reset.assert_not_called()
def test_heatmap_on_scan_status_same_scan_id(heatmap_widget):
scan_msg = messages.ScanStatusMessage(scan_id="123", status="open", metadata={}, info={})
heatmap_widget.scan_id = "123"
with mock.patch.object(heatmap_widget, "reset") as mock_reset:
heatmap_widget.on_scan_status(scan_msg.content, scan_msg.metadata)
mock_reset.assert_not_called()
def test_heatmap_widget_on_scan_status_different_scan_id(heatmap_widget):
scan_msg = messages.ScanStatusMessage(scan_id="123", status="open", metadata={}, info={})
heatmap_widget.scan_id = "456"
with mock.patch.object(heatmap_widget, "reset") as mock_reset:
heatmap_widget.on_scan_status(scan_msg.content, scan_msg.metadata)
mock_reset.assert_called_once()
def test_heatmap_get_image_data_missing_data(heatmap_widget):
"""
If the data is missing or incomplete, the method should return None.
"""
assert heatmap_widget.get_image_data() == (None, None)
def test_heatmap_get_image_data_grid_scan(heatmap_widget):
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={},
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
)
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
heatmap_widget.status_message = scan_msg
with mock.patch.object(heatmap_widget, "get_grid_scan_image") as mock_get_grid_scan_image:
heatmap_widget.get_image_data(x_data=[1, 2], y_data=[3, 4], z_data=[5, 6])
mock_get_grid_scan_image.assert_called_once()
def test_heatmap_get_image_data_step_scan(heatmap_widget):
"""
If the step scan has too few points, it should return None.
"""
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="step_scan",
scan_type="step",
metadata={},
info={"positions": [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]]},
)
with mock.patch.object(heatmap_widget, "get_step_scan_image") as mock_get_step_scan_image:
heatmap_widget.status_message = scan_msg
heatmap_widget.get_image_data(x_data=[1, 2, 3, 4], y_data=[1, 2, 3, 4], z_data=[1, 2, 5, 6])
mock_get_step_scan_image.assert_called_once()
def test_heatmap_get_image_data_step_scan_too_few_points(heatmap_widget):
"""
If the step scan has too few points, it should return None.
"""
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="step_scan",
scan_type="step",
metadata={},
info={"positions": [[1, 2], [3, 4]]},
)
heatmap_widget.status_message = scan_msg
out = heatmap_widget.get_image_data(x_data=[1, 2], y_data=[3, 4], z_data=[5, 6])
assert out == (None, None)
def test_heatmap_get_image_data_unsupported_scan(heatmap_widget):
scan_msg = messages.ScanStatusMessage(
scan_id="123", status="open", scan_type="fly", metadata={}, info={}
)
heatmap_widget.status_message = scan_msg
assert heatmap_widget.get_image_data(x_data=[1, 2], y_data=[3, 4], z_data=[5, 6]) == (
None,
None,
)
def test_heatmap_get_grid_scan_image(heatmap_widget):
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={"positions": np.random.rand(100, 2).tolist()},
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
)
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
img, _ = heatmap_widget.get_grid_scan_image(list(range(100)), msg=scan_msg)
assert img.shape == (10, 10)
assert sorted(np.asarray(img, dtype=int).flatten().tolist()) == list(range(100))
def test_heatmap_get_step_scan_image(heatmap_widget):
scan_msg = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="step_scan",
scan_type="step",
metadata={},
info={"positions": np.random.rand(100, 2).tolist()},
)
heatmap_widget.status_message = scan_msg
heatmap_widget.scan_item = create_dummy_scan_item()
heatmap_widget.scan_item.status_message = scan_msg
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
img, _ = heatmap_widget.get_step_scan_image(
list(np.random.rand(100)), list(np.random.rand(100)), list(range(100)), msg=scan_msg
)
assert img.shape > (10, 10)
def test_heatmap_update_plot_no_scan_item(heatmap_widget):
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
with mock.patch.object(heatmap_widget.main_image, "setImage") as mock_set_image:
heatmap_widget.update_plot(_override_slot_params={"verify_sender": False})
mock_set_image.assert_not_called()
def test_heatmap_update_plot(heatmap_widget):
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
heatmap_widget.scan_item = create_dummy_scan_item()
heatmap_widget.scan_item.status_message = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={"positions": np.random.rand(100, 2).tolist()},
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
)
with mock.patch.object(heatmap_widget.main_image, "setImage") as mock_set_image:
heatmap_widget.update_plot(_override_slot_params={"verify_sender": False})
img = mock_set_image.mock_calls[0].args[0]
assert img.shape == (10, 10)
def test_heatmap_update_plot_without_status_message(heatmap_widget):
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
heatmap_widget.scan_item = create_dummy_scan_item()
heatmap_widget.scan_item.status_message = None
with mock.patch.object(heatmap_widget.main_image, "setImage") as mock_set_image:
heatmap_widget.update_plot(_override_slot_params={"verify_sender": False})
mock_set_image.assert_not_called()
def test_heatmap_update_plot_no_img_data(heatmap_widget):
heatmap_widget._image_config = HeatmapConfig(
parent_id="parent_id",
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
color_map="viridis",
)
heatmap_widget.scan_item = create_dummy_scan_item()
heatmap_widget.scan_item.status_message = messages.ScanStatusMessage(
scan_id="123",
status="open",
scan_name="grid_scan",
metadata={},
info={},
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
)
with mock.patch.object(heatmap_widget, "get_image_data", return_value=None):
with mock.patch.object(heatmap_widget.main_image, "setImage") as mock_set_image:
heatmap_widget.update_plot(_override_slot_params={"verify_sender": False})
mock_set_image.assert_not_called()
def test_heatmap_settings_popup(heatmap_widget, qtbot):
"""
Test that the settings popup opens and contains the expected elements.
"""
settings_action = heatmap_widget.toolbar.components.get_action("heatmap_settings").action
heatmap_widget.show_heatmap_settings()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is not None)
assert heatmap_widget.heatmap_dialog.isVisible()
assert settings_action.isChecked()
heatmap_widget.heatmap_dialog.reject()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)
assert not settings_action.isChecked()
def test_heatmap_settings_popup_already_open(heatmap_widget, qtbot):
"""
Test that if the settings dialog is already open, it is brought to the front.
"""
heatmap_widget.show_heatmap_settings()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is not None)
initial_dialog = heatmap_widget.heatmap_dialog
heatmap_widget.show_heatmap_settings()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is initial_dialog)
assert heatmap_widget.heatmap_dialog.isVisible() # Dialog should still be visible
assert heatmap_widget.heatmap_dialog is initial_dialog # Should be the same dialog
heatmap_widget.heatmap_dialog.reject()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)
def test_heatmap_settings_popup_accept_changes(heatmap_widget, qtbot):
"""
Test that changes made in the settings dialog are applied correctly.
"""
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
assert heatmap_widget.color_map == "plasma" # Default colormap
heatmap_widget.show_heatmap_settings()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is not None)
dialog = heatmap_widget.heatmap_dialog
assert dialog.widget.isVisible()
# Simulate changing a setting
dialog.widget.ui.color_map.colormap = "viridis"
# Accept changes
dialog.accept()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)
# Verify that the setting was applied
assert heatmap_widget.color_map == "viridis"
def test_heatmap_settings_popup_show_settings(heatmap_widget, qtbot):
"""
Test that the settings dialog opens and contains the expected elements.
"""
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
heatmap_widget.show_heatmap_settings()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is not None)
dialog = heatmap_widget.heatmap_dialog
assert dialog.isVisible()
assert dialog.widget is not None
assert hasattr(dialog.widget.ui, "color_map")
assert hasattr(dialog.widget.ui, "x_name")
assert hasattr(dialog.widget.ui, "y_name")
assert hasattr(dialog.widget.ui, "z_name")
# Check that the ui elements are correctly initialized
assert dialog.widget.ui.color_map.colormap == heatmap_widget.color_map
assert dialog.widget.ui.x_name.currentText() == heatmap_widget._image_config.x_device.name
dialog.reject()
qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)
def test_heatmap_widget_reset(heatmap_widget):
"""
Test that the reset method clears the plot.
"""
heatmap_widget.scan_item = create_dummy_scan_item()
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
heatmap_widget.reset()
assert heatmap_widget._grid_index is None
assert heatmap_widget.main_image.raw_data is None
def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_history_msg, qtbot):
"""
Test that the update_plot method updates the plot with scan history.
"""
heatmap_widget.client.history = ScanHistory(heatmap_widget.client, False)
heatmap_widget.client.history._scan_data[grid_scan_history_msg.scan_id] = grid_scan_history_msg
heatmap_widget.client.history._scan_ids.append(grid_scan_history_msg.scan_id)
heatmap_widget.client.queue.scan_storage.current_scan = None
heatmap_widget.plot(
x_name="samx",
y_name="samy",
z_name="bpm4i",
x_entry="samx",
y_entry="samy",
z_entry="bpm4i",
)
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data is not None)
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (10, 10))
heatmap_widget.enforce_interpolation = True
heatmap_widget.oversampling_factor = 2.0
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (20, 20))

View File

@@ -0,0 +1,39 @@
import pytest
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
@pytest.fixture
def monaco_widget(qtbot):
widget = MonacoWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_monaco_widget_set_text(monaco_widget: MonacoWidget, qtbot):
"""
Test that the MonacoWidget can set text correctly.
"""
test_text = "Hello, Monaco!"
monaco_widget.set_text(test_text)
qtbot.waitUntil(lambda: monaco_widget.get_text() == test_text, timeout=1000)
assert monaco_widget.get_text() == test_text
def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot):
"""
Test that the MonacoWidget can be set to read-only mode.
"""
monaco_widget.set_text("Initial text")
qtbot.waitUntil(lambda: monaco_widget.get_text() == "Initial text", timeout=1000)
monaco_widget.set_readonly(True)
with pytest.raises(ValueError):
monaco_widget.set_text("This should not change")
monaco_widget.set_readonly(False) # Set back to editable
qtbot.wait(100)
monaco_widget.set_text("Attempting to change text")
qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000)
assert monaco_widget.get_text() == "Attempting to change text"

View File

@@ -1,3 +1,5 @@
import numpy as np
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from .client_mocks import mocked_client
@@ -126,6 +128,31 @@ def test_auto_range_x_y(qtbot, mocked_client):
assert pb.plot_item.vb.state["autoRange"][1] is False
def test_autorange_respects_visibility(qtbot, mocked_client):
"""
Autorange must consider only the curves whose .isVisible() flag is True.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
x = np.arange(10)
small = pb.plot_item.plot(x, x, pen=(255, 0, 0)) # 09
medium = pb.plot_item.plot(x, x * 10, pen=(0, 255, 0)) # 090
large = pb.plot_item.plot(x, x * 100, pen=(0, 0, 255)) # 0900
pb.auto_range(True)
qtbot.wait(200)
yspan_full = pb.plot_item.vb.viewRange()[1]
assert yspan_full[1] > 800, "Autorange must include the largest visible curve."
# Hide the largest curve, recompute autorange, and expect the span to shrink.
large.setVisible(False)
pb.auto_range(True)
qtbot.wait(200)
yspan_reduced = pb.plot_item.vb.viewRange()[1]
assert yspan_reduced[1] < 200, "Hidden curves must be excluded from autorange."
def test_x_log_y_log(qtbot, mocked_client):
"""
Test toggling log scale on x and y axes.

View File

@@ -0,0 +1,380 @@
from unittest import mock
import pytest
from bec_lib.messages import ScanHistoryMessage, _StoredDataInfo
from pytestqt import qtbot
from qtpy import QtCore
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
ScanHistoryMetadataViewer,
ScanHistoryView,
)
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_history_msg():
"""Fixture to create a mock ScanHistoryMessage."""
yield ScanHistoryMessage(
scan_id="test_scan",
dataset_number=1,
scan_number=1,
scan_name="Test Scan",
file_path="/path/to/scan",
start_time=1751957906.3310962,
end_time=1751957907.3310962, # 1s later
exit_status="closed",
num_points=10,
request_inputs={"some_input": "value"},
stored_data_info={
"device2": {
"device2_signal1": _StoredDataInfo(shape=(10,)),
"device2_signal2": _StoredDataInfo(shape=(20,)),
"device2_signal3": _StoredDataInfo(shape=(25,)),
},
"device3": {"device3_signal1": _StoredDataInfo(shape=(1,))},
},
)
@pytest.fixture
def scan_history_msg_2():
"""Fixture to create a second mock ScanHistoryMessage."""
yield ScanHistoryMessage(
scan_id="test_scan_2",
dataset_number=2,
scan_number=2,
scan_name="Test Scan 2",
file_path="/path/to/scan_2",
start_time=1751957908.3310962,
end_time=1751957909.3310962, # 1s later
exit_status="closed",
num_points=5,
request_inputs={"some_input": "new_value"},
stored_data_info={
"device0": {
"device0_signal1": _StoredDataInfo(shape=(15,)),
"device0_signal2": _StoredDataInfo(shape=(25,)),
"device0_signal3": _StoredDataInfo(shape=(3,)),
"device0_signal4": _StoredDataInfo(shape=(20,)),
},
"device2": {
"device2_signal1": _StoredDataInfo(shape=(10,)),
"device2_signal2": _StoredDataInfo(shape=(20,)),
"device2_signal3": _StoredDataInfo(shape=(25,)),
"device2_signal4": _StoredDataInfo(shape=(30,)),
},
"device1": {"device1_signal1": _StoredDataInfo(shape=(25,))},
},
)
@pytest.fixture
def scan_history_device_viewer(qtbot, mocked_client):
widget = ScanHistoryDeviceViewer(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def scan_history_metadata_viewer(qtbot, mocked_client):
widget = ScanHistoryMetadataViewer(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def scan_history_view(qtbot, mocked_client):
widget = ScanHistoryView(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def scan_history_browser(qtbot, mocked_client):
"""Fixture to create a ScanHistoryBrowser widget."""
widget = ScanHistoryBrowser(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_scan_history_device_viewer_receive_msg(
qtbot, scan_history_device_viewer, scan_history_msg, scan_history_msg_2
):
"""Test updating devices from scan history."""
# Update with first scan history message
assert scan_history_device_viewer.scan_history_msg is None
assert scan_history_device_viewer.signal_model.signals == []
assert scan_history_device_viewer.signal_model.rowCount() == 0
scan_history_device_viewer.update_devices_from_scan_history(
scan_history_msg.content, scan_history_msg.metadata
)
assert scan_history_device_viewer.scan_history_msg == scan_history_msg
assert scan_history_device_viewer.device_combo.currentText() == "device2"
assert scan_history_device_viewer.signal_model.signals == [
("device2_signal3", _StoredDataInfo(shape=(25,))),
("device2_signal2", _StoredDataInfo(shape=(20,))),
("device2_signal1", _StoredDataInfo(shape=(10,))),
]
current_index = scan_history_device_viewer.signal_combo.currentIndex()
assert current_index == 0
signal_name = scan_history_device_viewer.signal_combo.model().data(
scan_history_device_viewer.signal_combo.model().index(current_index, 0), QtCore.Qt.UserRole
)
assert signal_name == "device2_signal3"
## Update of second message should not change the device if still available
new_msg = scan_history_msg_2
scan_history_device_viewer.update_devices_from_scan_history(new_msg.content, new_msg.metadata)
assert scan_history_device_viewer.scan_history_msg == new_msg
assert scan_history_device_viewer.signal_model.signals == [
("device2_signal4", _StoredDataInfo(shape=(30,))),
("device2_signal3", _StoredDataInfo(shape=(25,))),
("device2_signal2", _StoredDataInfo(shape=(20,))),
("device2_signal1", _StoredDataInfo(shape=(10,))),
]
assert scan_history_device_viewer.device_combo.currentText() == "device2"
current_index = scan_history_device_viewer.signal_combo.currentIndex()
assert current_index == 1
signal_name = scan_history_device_viewer.signal_combo.model().data(
scan_history_device_viewer.signal_combo.model().index(current_index, 0), QtCore.Qt.UserRole
)
assert signal_name == "device2_signal3"
def test_scan_history_device_viewer_clear_view(qtbot, scan_history_device_viewer, scan_history_msg):
"""Test clearing the device viewer."""
scan_history_device_viewer.update_devices_from_scan_history(scan_history_msg.content)
assert scan_history_device_viewer.scan_history_msg == scan_history_msg
scan_history_device_viewer.clear_view()
assert scan_history_device_viewer.scan_history_msg is None
assert scan_history_device_viewer.device_combo.model().rowCount() == 0
def test_scan_history_device_viewer_on_request_plotting_clicked(
qtbot, scan_history_device_viewer, scan_history_msg
):
"""Test the request plotting button click."""
scan_history_device_viewer.update_devices_from_scan_history(scan_history_msg.content)
plotting_callback_args = []
def plotting_callback(device_name, signal_name, msg):
"""Callback to check if the request plotting signal is emitted."""
plotting_callback_args.append((device_name, signal_name, msg))
scan_history_device_viewer.request_history_plot.connect(plotting_callback)
qtbot.mouseClick(scan_history_device_viewer.request_plotting_button, QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: len(plotting_callback_args) > 0, timeout=5000)
# scan_id
assert plotting_callback_args[0][0] == scan_history_msg.scan_id
# device_name
assert plotting_callback_args[0][1] in scan_history_msg.stored_data_info.keys()
# signal_name
assert (
plotting_callback_args[0][2]
in scan_history_msg.stored_data_info[plotting_callback_args[0][1]].keys()
)
def test_scan_history_metadata_viewer_receive_msg(
qtbot, scan_history_metadata_viewer, scan_history_msg
):
"""Test the initialization of ScanHistoryMetadataViewer."""
assert scan_history_metadata_viewer.scan_history_msg is None
assert scan_history_metadata_viewer.title() == "No Scan Selected"
scan_history_metadata_viewer.update_view(scan_history_msg.content)
assert scan_history_metadata_viewer.scan_history_msg == scan_history_msg
assert scan_history_metadata_viewer.title() == f"Metadata - Scan {scan_history_msg.scan_number}"
for row, k in enumerate(scan_history_metadata_viewer._scan_history_msg_labels.keys()):
if k == "elapsed_time":
scan_history_metadata_viewer.layout().itemAtPosition(row, 1).widget().text() == "1.000s"
if k == "scan_name":
scan_history_metadata_viewer.layout().itemAtPosition(
row, 1
).widget().text() == "Test Scan"
def test_scan_history_metadata_viewer_clear_view(
qtbot, scan_history_metadata_viewer, scan_history_msg
):
"""Test clearing the metadata viewer."""
scan_history_metadata_viewer.update_view(scan_history_msg.content)
assert scan_history_metadata_viewer.scan_history_msg == scan_history_msg
scan_history_metadata_viewer.clear_view()
assert scan_history_metadata_viewer.scan_history_msg is None
assert scan_history_metadata_viewer.title() == "No Scan Selected"
def test_scan_history_view(qtbot, scan_history_view, scan_history_msg):
"""Test the initialization of ScanHistoryView."""
assert scan_history_view.scan_history == []
assert scan_history_view.topLevelItemCount() == 0
header = scan_history_view.headerItem()
assert [header.text(i) for i in range(header.columnCount())] == [
"Scan Nr",
"Scan Name",
"Status",
]
def test_scan_history_view_add_remove_scan(qtbot, scan_history_view, scan_history_msg):
"""Test adding a scan to the ScanHistoryView."""
scan_history_view.update_history(scan_history_msg.model_dump())
assert len(scan_history_view.scan_history) == 1
assert scan_history_view.scan_history[0] == scan_history_msg
assert scan_history_view.topLevelItemCount() == 1
tree_item = scan_history_view.topLevelItem(0)
tree_item.text(0) == str(scan_history_msg.scan_number)
tree_item.text(1) == scan_history_msg.scan_name
tree_item.text(2) == ""
# remove scan
def remove_callback(msg):
"""Callback to check if the no_scan_selected signal is emitted."""
assert msg == scan_history_msg
scan_history_view.remove_scan(0)
assert len(scan_history_view.scan_history) == 0
assert scan_history_view.topLevelItemCount() == 0
def test_scan_history_view_current_scan_item_changed(
qtbot, scan_history_view, scan_history_msg, scan_history_device_viewer
):
"""Test the current scan item changed signal."""
scan_history_view.update_history(scan_history_msg.model_dump())
scan_history_msg.scan_id = "test_scan_2"
scan_history_view.update_history(scan_history_msg.model_dump())
scan_history_msg.scan_id = "test_scan_3"
scan_history_view.update_history(scan_history_msg.model_dump())
assert len(scan_history_view.scan_history) == 3
def scan_selected_callback(msg):
"""Callback to check if the scan_selected signal is emitted."""
return msg == scan_history_msg
scan_history_view.scan_selected.connect(scan_selected_callback)
qtbot.mouseClick(
scan_history_view.viewport(),
QtCore.Qt.LeftButton,
pos=scan_history_view.visualItemRect(scan_history_view.topLevelItem(0)).center(),
)
def test_scan_history_view_refresh(qtbot, scan_history_view, scan_history_msg, scan_history_msg_2):
"""Test the refresh method of ScanHistoryView."""
scan_history_view.update_history(scan_history_msg.model_dump())
scan_history_view.update_history(scan_history_msg_2.model_dump())
assert len(scan_history_view.scan_history) == 2
with mock.patch.object(
scan_history_view.bec_scan_history_manager, "refresh_scan_history"
) as mock_refresh:
scan_history_view.refresh()
mock_refresh.assert_called_once()
assert len(scan_history_view.scan_history) == 0
assert scan_history_view.topLevelItemCount() == 0
def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, scan_history_msg_2):
"""Test the initialization of ScanHistoryBrowser."""
assert isinstance(scan_history_browser.scan_history_view, ScanHistoryView)
assert isinstance(scan_history_browser.scan_history_metadata_viewer, ScanHistoryMetadataViewer)
assert isinstance(scan_history_browser.scan_history_device_viewer, ScanHistoryDeviceViewer)
# Add 2 scans to the history browser, new item will be added to the top
scan_history_browser.scan_history_view.update_history(scan_history_msg.model_dump())
scan_history_browser.scan_history_view.update_history(scan_history_msg_2.model_dump())
assert len(scan_history_browser.scan_history_view.scan_history) == 2
# Click on first scan item history to select it
qtbot.mouseClick(
scan_history_browser.scan_history_view.viewport(),
QtCore.Qt.LeftButton,
pos=scan_history_browser.scan_history_view.visualItemRect(
scan_history_browser.scan_history_view.topLevelItem(0)
).center(),
)
assert scan_history_browser.scan_history_view.currentIndex().row() == 0
# Both metadata and device viewers should be updated with the first scan
qtbot.waitUntil(
lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg
== scan_history_msg_2,
timeout=2000,
)
qtbot.waitUntil(
lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg
== scan_history_msg_2,
timeout=2000,
)
# TODO #771 ; Multiple clicks to the QTreeView item fail, but only in the CI, not locally.
# Click on second scan item history to select it
# qtbot.mouseClick(
# scan_history_browser.scan_history_view.viewport(),
# QtCore.Qt.LeftButton,
# pos=scan_history_browser.scan_history_view.visualItemRect(
# scan_history_browser.scan_history_view.topLevelItem(1)
# ).center(),
# )
# assert scan_history_browser.scan_history_view.currentIndex().row() == 1
# # Both metadata and device viewers should be updated with the first scan
# qtbot.waitUntil(
# lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg
# == scan_history_msg,
# timeout=2000,
# )
# qtbot.waitUntil(
# lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg
# == scan_history_msg,
# timeout=2000,
# )
callback_args = []
def plotting_callback(device_name, signal_name, msg):
"""Callback to check if the request plotting signal is emitted."""
# device_name should be the first device
callback_args.append((device_name, signal_name, msg))
scan_history_browser.scan_history_device_viewer.request_history_plot.connect(plotting_callback)
# Test emit plotting request
qtbot.mouseClick(
scan_history_browser.scan_history_device_viewer.request_plotting_button,
QtCore.Qt.LeftButton,
)
qtbot.waitUntil(lambda: len(callback_args) > 0, timeout=5000)
assert callback_args[0][0] == scan_history_msg_2.scan_id
device_name = callback_args[0][1]
signal_name = callback_args[0][2]
assert device_name in scan_history_msg_2.stored_data_info.keys()
assert signal_name in scan_history_msg_2.stored_data_info[device_name].keys()
# Test clearing the view, removing both scans
scan_history_browser.scan_history_view.remove_scan(-1)
scan_history_browser.scan_history_view.remove_scan(-1)
assert len(scan_history_browser.scan_history_view.scan_history) == 0
assert scan_history_browser.scan_history_view.topLevelItemCount() == 0
qtbot.waitUntil(
lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg is None,
timeout=2000,
)
qtbot.waitUntil(
lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg is None,
timeout=2000,
)

View File

@@ -142,6 +142,7 @@ def test_choose_signal_dialog_sends_choices(signal_label: SignalLabel, qtbot):
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["test device"] = MagicMock()
dialog._device_field.setText("test device")
dialog._signal_field._signals = [("test signal", {"component_name": "test signal"})]
dialog._signal_field.addItem("test signal")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
@@ -154,6 +155,7 @@ def test_dialog_handler_updates_devices(signal_label: SignalLabel, qtbot):
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
dialog._device_field.dev["flux_capacitor"] = MagicMock()
dialog._device_field.setText("flux_capacitor")
dialog._signal_field._signals = [("spin_speed", {"component_name": "spin_speed"})]
dialog._signal_field.addItem("spin_speed")
dialog._signal_field.setCurrentIndex(0)
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)

View File

@@ -0,0 +1,117 @@
import pytest
from qtpy.QtCore import QPoint, QSize, Qt
from qtpy.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.widget_finder.widget_finder import WidgetFinderComboBox
from tests.unit_tests.conftest import create_widget
@pytest.fixture
def finder_fixture(qtbot):
central_widget = QWidget()
central_widget.layout = QVBoxLayout(central_widget)
# Create some buttons and a label under parent
btn1 = QPushButton("Button1", central_widget)
btn1.setObjectName("btn1")
btn2 = QPushButton("Button2", central_widget)
btn2.setObjectName("btn2")
lbl1 = QLabel("Label1", central_widget)
lbl1.setObjectName("lbl1")
# Instantiate finder to look for QPushButton
finder = WidgetFinderComboBox(central_widget, QPushButton)
# Add buttons and label to the layout
central_widget.layout.addWidget(btn1)
central_widget.layout.addWidget(btn2)
central_widget.layout.addWidget(lbl1)
central_widget.layout.addWidget(finder)
qtbot.addWidget(central_widget)
qtbot.waitExposed(central_widget)
return finder, central_widget, btn1, btn2, lbl1
def test_initial_list_contains_buttons_only(qtbot, finder_fixture):
finder, parent, btn1, btn2, lbl1 = finder_fixture
items = [finder.itemText(i) for i in range(finder.count())]
assert "btn1" in items
assert "btn2" in items
assert "lbl1" not in items
def test_refresh_and_show_popup_update_list(finder_fixture, qtbot):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Dynamically add a third button
btn3 = QPushButton("Button3", parent)
btn3.setObjectName("btn3")
# Manual refresh
qtbot.mouseClick(finder.refresh_button, Qt.LeftButton)
items = [finder.itemText(i) for i in range(finder.count())]
assert "btn3" in items
# And via showPopup
btn4 = QPushButton("Button4", parent)
btn4.setObjectName("btn4")
finder.showPopup()
items = [finder.itemText(i) for i in range(finder.count())]
assert "btn4" in items
def test_selected_widget_and_widget_class_name_setter(finder_fixture, qtbot):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Select btn2
idx = finder.findText("btn2")
finder.setCurrentIndex(idx)
qtbot.wait(200) # allow refresh_list to run
selected_widget = finder.selected_widget
assert selected_widget == btn2
# Now switch to QLabel via the property setter
finder.widget_class_name = "QLabel"
qtbot.wait(200) # allow refresh_list to run
items = [finder.itemText(i) for i in range(finder.count())]
assert "lbl1" in items
assert "btn1" not in items
def test_inspect_widget_highlights_button(qtbot, finder_fixture):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Select btn1 and inspect
idx = finder.findText("btn1")
finder.setCurrentIndex(idx)
finder.inspect_widget()
qtbot.wait(100) # allow highlighter to show
highlighter = finder.highlighter
assert highlighter.isVisible()
qtbot.wait(500) # wait ≥ pulse duration
# Highlighter should match the target widget size
expected_size = btn1.frameGeometry().size()
assert highlighter.geometry().size() == expected_size
def test_inspect_widget_highlights_label(qtbot, finder_fixture):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Switch to QLabel and inspect lbl1
finder.widget_class_name = "QLabel"
qtbot.wait(50) # allow refresh
idx = finder.findText("lbl1")
finder.setCurrentIndex(idx)
finder.inspect_widget()
qtbot.wait(100) # allow highlighter to show
highlighter = finder.highlighter
assert highlighter.isVisible()
qtbot.wait(500) # wait ≥ pulse duration
# Highlighter should match the target widget size
expected_size = lbl1.frameGeometry().size()
assert highlighter.geometry().size() == expected_size

View File

@@ -190,3 +190,23 @@ def test_widget_io_signal(qtbot, example_widget):
toggle.checked = False
qtbot.waitUntil(lambda: len(changes) > 4)
assert changes[-1][1] == False
def test_find_widgets(example_widget):
# Test find_widgets by class type
line_edits = WidgetIO.find_widgets(QLineEdit)
assert len(line_edits) == 2 # one LineEdit and one in the SpinBox
assert isinstance(line_edits[0], QLineEdit)
# Test find_widgets by class-name string
combo_boxes = WidgetIO.find_widgets("QComboBox")
assert len(combo_boxes) == 1
assert isinstance(combo_boxes[0], QComboBox)
# Test non-recursive search returns the same widgets
combo_boxes_flat = WidgetIO.find_widgets(QComboBox, recursive=False)
assert combo_boxes_flat == combo_boxes
# Test search for non-existent widget returns empty list
non_exist = WidgetIO.find_widgets("NonExistentWidget")
assert non_exist == []