mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-17 14:05:35 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b75d5600a | ||
| 01c6e092b9 | |||
|
|
ca6f355aac | ||
| d876ca72bc | |||
| e0fd97616d | |||
| 6af8a5cbfe | |||
|
|
944e2cedf8 | ||
| cd11a6cce3 | |||
|
|
c98106e594 | ||
| 04f1ff4fe7 | |||
|
|
45ed92494c | ||
| 5fc96bd299 | |||
|
|
1ad5df57fe | ||
| 440e778162 | |||
| fdeb8fcb0f | |||
| 5c90983dd4 | |||
| 4171de1e45 | |||
|
|
f12339e6f9 | ||
| ce8e5f0bec | |||
|
|
7ea9ab5175 | ||
| b72f0dc6e8 | |||
|
|
cb9d429884 | ||
| 0a80bd0a92 | |||
|
|
9bc9d355e2 | ||
| 7d5e702a11 | |||
| 40cbf7fe4f | |||
|
|
7b287c45f2 | ||
| c9455672b5 | |||
|
|
7f06375f9d | ||
| d00d786399 | |||
| a4c465dcaf | |||
|
|
d0e94d0da4 | ||
| bb3cea7fe8 | |||
|
|
3c6aa8e138 | ||
| 198684c65d | |||
| 617f2df2af |
141
CHANGELOG.md
141
CHANGELOG.md
@@ -1,6 +1,147 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.45.13 (2025-12-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan queue**: Adjustments for changes to the pydantic model of the scan queue
|
||||
([`01c6e09`](https://github.com/bec-project/bec_widgets/commit/01c6e092b9cd46ae056c43e8c6576f7a570cce80))
|
||||
|
||||
|
||||
## v2.45.12 (2025-12-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **heatmap**: Flush image if config changes during scan
|
||||
([`e0fd976`](https://github.com/bec-project/bec_widgets/commit/e0fd97616d370722e2ebf12d0f93862ac35cb20d))
|
||||
|
||||
- **heatmap**: Grid scan image correctly map to scan positions
|
||||
([`6af8a5c`](https://github.com/bec-project/bec_widgets/commit/6af8a5cbfe0f97327b31039033d3e6946388347c))
|
||||
|
||||
- **heatmap**: More robust logic for fast and slow axis in grid scan
|
||||
([`d876ca7`](https://github.com/bec-project/bec_widgets/commit/d876ca72bc50f967f0872eb777f2378a3db68ddf))
|
||||
|
||||
|
||||
## v2.45.11 (2025-12-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Support for AsyncMultiSignal
|
||||
([`cd11a6c`](https://github.com/bec-project/bec_widgets/commit/cd11a6cce33f3c0642984ae6b2d159c7441e22c6))
|
||||
|
||||
|
||||
## v2.45.10 (2025-12-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **devices**: Minor fix to comply with new config helper in bec_lib
|
||||
([`04f1ff4`](https://github.com/bec-project/bec_widgets/commit/04f1ff4fe7869215f010bf73f7271e063e21f2a2))
|
||||
|
||||
|
||||
## v2.45.9 (2025-12-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc**: Add expiration to GUI registry state updates
|
||||
([`5fc96bd`](https://github.com/bec-project/bec_widgets/commit/5fc96bd299115c1849240bae3b37112aad8f5a54))
|
||||
|
||||
|
||||
## v2.45.8 (2025-12-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **notification_banner**: Backwards compatibility to push messages from Broker to Centre as dict
|
||||
([`440e778`](https://github.com/bec-project/bec_widgets/commit/440e778162ebb359fc33be26e3d22f99b4f9dcfe))
|
||||
|
||||
- **notification_banner**: Better contrast in light mode
|
||||
([`5c90983`](https://github.com/bec-project/bec_widgets/commit/5c90983dd4c3ff96e5625ebda0054a1ac1256227))
|
||||
|
||||
- **notification_banner**: Expired messages are hidden in notification center but still accessible
|
||||
([`4171de1`](https://github.com/bec-project/bec_widgets/commit/4171de1e454c4832513ca599c0fd0eaa365c7c32))
|
||||
|
||||
- **notification_banner**: Formatted error messages fetched directly from BECMessage; do not repreat
|
||||
notifications ids
|
||||
([`fdeb8fc`](https://github.com/bec-project/bec_widgets/commit/fdeb8fcb0f223d64933f2791585756527c2f41ed))
|
||||
|
||||
|
||||
## v2.45.7 (2025-12-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Handle none in literal combobox
|
||||
([`ce8e5f0`](https://github.com/bec-project/bec_widgets/commit/ce8e5f0bec7643c9f826e06f987775de95abb91d))
|
||||
|
||||
|
||||
## v2.45.6 (2025-11-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **curve**: Update dap curves if data are set manually
|
||||
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
|
||||
|
||||
|
||||
## v2.45.5 (2025-11-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove ghost widgets in scan metadata
|
||||
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
|
||||
|
||||
|
||||
## v2.45.4 (2025-11-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Removed hiding scan progressbar animation
|
||||
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
|
||||
|
||||
- **web_links**: Fixed link to bec widget issues from gitlab to github
|
||||
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
|
||||
|
||||
|
||||
## v2.45.3 (2025-11-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **fakeredis**: Add support for additional args
|
||||
([`c945567`](https://github.com/bec-project/bec_widgets/commit/c9455672b58b9df101ccd0d80a169bdf6c707f34))
|
||||
|
||||
|
||||
## v2.45.2 (2025-11-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **test**: Removed duplicate test in crosshair
|
||||
([`d00d786`](https://github.com/bec-project/bec_widgets/commit/d00d786399bca516b8030b9de881b674140bf439))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyqtgraph pin to 0.13.7
|
||||
([`a4c465d`](https://github.com/bec-project/bec_widgets/commit/a4c465dcaf8cb03962dec1e360b7b832a9a5c780))
|
||||
|
||||
|
||||
## v2.45.1 (2025-11-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Async_readback can accept 0D data
|
||||
([`bb3cea7`](https://github.com/bec-project/bec_widgets/commit/bb3cea7fe800cd5375de5351a72e0944dc86861f))
|
||||
|
||||
|
||||
## v2.45.0 (2025-11-10)
|
||||
|
||||
### Chores
|
||||
|
||||
- Add third-party license notice
|
||||
([`617f2df`](https://github.com/bec-project/bec_widgets/commit/617f2df2af41db7692c42d0e10bce4968f36fb94))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Dap curve can be attached to custom and history curves
|
||||
([`198684c`](https://github.com/bec-project/bec_widgets/commit/198684c65d9565e8985156b426b8ef98dcc687cc))
|
||||
|
||||
|
||||
## v2.44.0 (2025-11-05)
|
||||
|
||||
### Chores
|
||||
|
||||
28
THIRD-PARTY-LICENCES
Normal file
28
THIRD-PARTY-LICENCES
Normal file
@@ -0,0 +1,28 @@
|
||||
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
|
||||
|
||||
Core Dependencies:
|
||||
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
|
||||
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
|
||||
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
|
||||
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
|
||||
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
|
||||
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
|
||||
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
|
||||
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
|
||||
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
|
||||
|
||||
|
||||
Additional Dependencies (Testing/Development):
|
||||
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
|
||||
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
|
||||
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
|
||||
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
|
||||
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
|
||||
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
|
||||
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
|
||||
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
|
||||
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
|
||||
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
|
||||
|
||||
@@ -5439,9 +5439,9 @@ class Waveform(RPCBase):
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -5461,11 +5461,12 @@ class Waveform(RPCBase):
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
Create a new DAP curve referencing the existing curve `device_label`, with the
|
||||
data processing model `dap_name`. DAP curves can be attached to curves that
|
||||
originate from live devices, history, or fully custom data sources.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
|
||||
@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(item.name)
|
||||
label = QLabel(parent=self._form_grid, text=item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
gl = self._form_grid.layout()
|
||||
while w := gl.takeAt(0):
|
||||
w = w.widget()
|
||||
if hasattr(w, "teardown"):
|
||||
w.teardown()
|
||||
w.deleteLater()
|
||||
self._form_grid_container.layout().removeWidget(self._form_grid)
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
|
||||
@@ -3,14 +3,16 @@ from __future__ import annotations
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from types import GenericAlias, NoneType, UnionType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
OrderedDict,
|
||||
TypeVar,
|
||||
get_args,
|
||||
@@ -71,7 +73,7 @@ class FormItemSpec(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
item_type: type | UnionType | GenericAlias
|
||||
item_type: type | UnionType | GenericAlias | Optional[Any]
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
pretty_display: bool = Field(
|
||||
@@ -188,6 +190,10 @@ class DynamicFormItem(QWidget):
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self, *_):
|
||||
return
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
@@ -204,11 +210,17 @@ class DynamicFormItem(QWidget):
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
|
||||
self._clear_button.clicked.connect(self.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
def teardown(self):
|
||||
self._layout.deleteLater()
|
||||
self._layout.removeWidget(self._main_widget)
|
||||
self._main_widget.deleteLater()
|
||||
self._main_widget = None
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
@@ -542,11 +554,14 @@ class StrLiteralFormItem(DynamicFormItem):
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.currentIndex() == -1:
|
||||
return None
|
||||
return self._main_widget.currentText()
|
||||
|
||||
def setValue(self, value: str | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
return
|
||||
for i in range(self._main_widget.count()):
|
||||
if self._main_widget.itemText(i) == value:
|
||||
self._main_widget.setCurrentIndex(i)
|
||||
@@ -557,15 +572,39 @@ class StrLiteralFormItem(DynamicFormItem):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
class OptionalStrLiteralFormItem(StrLiteralFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QComboBox()
|
||||
self._options = get_args(get_args(self._spec.info.annotation)[0])
|
||||
for opt in self._options:
|
||||
self._main_widget.addItem(opt)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
|
||||
|
||||
def _is_string_literal(t: type):
|
||||
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
|
||||
|
||||
|
||||
def _is_optional_string_literal(t: type):
|
||||
if not hasattr(t, "__args__"):
|
||||
return False
|
||||
if len(t.__args__) != 2:
|
||||
return False
|
||||
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
# and delete/insert keys or change the order
|
||||
"literal_str": (
|
||||
lambda spec: type(spec.info.annotation) is type(Literal[""])
|
||||
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
|
||||
StrLiteralFormItem,
|
||||
"literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
|
||||
"optional_literal_str": (
|
||||
lambda spec: _is_optional_string_literal(spec.info.annotation),
|
||||
OptionalStrLiteralFormItem,
|
||||
),
|
||||
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
|
||||
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
|
||||
@@ -616,6 +655,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
literal: Literal["a", "b", "c"]
|
||||
nullable_literal: Literal["a", "b", "c"] | None = None
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
@@ -623,7 +664,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
|
||||
@@ -229,6 +229,7 @@ class RPCServer:
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1,
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
|
||||
|
||||
@@ -14,13 +14,14 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.alarm_handler import Alarms # external enum
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import ErrorInfo
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QObject, QTimer
|
||||
@@ -28,6 +29,7 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@@ -53,10 +55,10 @@ DARK_PALETTE = {
|
||||
}
|
||||
|
||||
LIGHT_PALETTE = {
|
||||
"base": "#e9ecef",
|
||||
"title": "#212121",
|
||||
"body": "#424242",
|
||||
"separator": "rgba(0,0,0,40)",
|
||||
"base": "#f5f5f7",
|
||||
"title": "#111827",
|
||||
"body": "#374151",
|
||||
"separator": "rgba(15,23,42,40)",
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ class NotificationToast(QFrame):
|
||||
self._kind = kind if isinstance(kind, SeverityKind) else SeverityKind(kind)
|
||||
self._traceback = traceback
|
||||
self._accent_color = QtGui.QColor(SEVERITY[self._kind.value]["color"])
|
||||
self._accent_alpha = 50
|
||||
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
|
||||
self.created = datetime.now()
|
||||
@@ -379,22 +382,31 @@ class NotificationToast(QFrame):
|
||||
|
||||
# buttons (text colour)
|
||||
base_btn_color = palette["title"]
|
||||
card_bg = QtGui.QColor(palette["base"])
|
||||
# tune card background and hover contrast per theme
|
||||
if theme == "light":
|
||||
card_bg.setAlphaF(0.98)
|
||||
btn_hover = self._accent_color.darker(105).name()
|
||||
else:
|
||||
card_bg.setAlphaF(0.88)
|
||||
btn_hover = self._accent_color.name()
|
||||
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
#NotificationToast {
|
||||
background: transparent;
|
||||
f"""
|
||||
#NotificationToast {{
|
||||
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
||||
border-radius: 12px;
|
||||
color: %s;
|
||||
}
|
||||
#NotificationToast QPushButton {
|
||||
color: {base_btn_color};
|
||||
border: 1px solid {palette["separator"]};
|
||||
}}
|
||||
#NotificationToast QPushButton {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: %s;
|
||||
color: {base_btn_color};
|
||||
font-size: 14px;
|
||||
}
|
||||
#NotificationToast QPushButton:hover { color: %s; }
|
||||
}}
|
||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||
"""
|
||||
% (base_btn_color, base_btn_color, self._accent_color.name())
|
||||
)
|
||||
# traceback panel colours
|
||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||
@@ -407,6 +419,37 @@ class NotificationToast(QFrame):
|
||||
"""
|
||||
)
|
||||
|
||||
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
||||
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
||||
icon = material_icon(
|
||||
icon_name=SEVERITY[self._kind.value]["icon"],
|
||||
color=icon_fg,
|
||||
filled=True,
|
||||
size=(24, 24),
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
self._icon_btn.setIcon(icon)
|
||||
|
||||
badge_bg = QtGui.QColor(self._accent_color)
|
||||
if theme == "light":
|
||||
# darken and strengthen the badge on light cards for contrast
|
||||
badge_bg = badge_bg.darker(115)
|
||||
badge_bg.setAlphaF(0.70)
|
||||
else:
|
||||
badge_bg.setAlphaF(0.30)
|
||||
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
# stronger accent wash in light mode, slightly stronger in dark too
|
||||
self._accent_alpha = 110 if theme == "light" else 60
|
||||
self.update()
|
||||
|
||||
########################################
|
||||
@@ -488,7 +531,9 @@ class NotificationToast(QFrame):
|
||||
# accent gradient, fades to transparent
|
||||
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
|
||||
accent = QtGui.QColor(self._accent_color)
|
||||
accent.setAlpha(50)
|
||||
if getattr(self, "_theme", "dark") == "light":
|
||||
accent = accent.darker(115)
|
||||
accent.setAlpha(getattr(self, "_accent_alpha", 50))
|
||||
grad.setColorAt(0.0, accent)
|
||||
fade = QtGui.QColor(self._accent_color)
|
||||
fade.setAlpha(0)
|
||||
@@ -690,7 +735,6 @@ class NotificationCentre(QScrollArea):
|
||||
toast.notification_id = notification_id
|
||||
broker = BECNotificationBroker()
|
||||
toast.closed.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
|
||||
toast.expired.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
|
||||
toast.closed.connect(lambda: self._hide_notification(toast))
|
||||
toast.expired.connect(lambda t=toast: self._handle_expire(t))
|
||||
toast.expanded.connect(self._adjust_height)
|
||||
@@ -1016,32 +1060,55 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
"""
|
||||
Called when a new alarm arrives. Builds and pushes a toast to each centre
|
||||
with a shared notification_id, and hooks its close/expire signals.
|
||||
|
||||
Args:
|
||||
msg(dict): The message containing alarm details.
|
||||
meta(dict): Metadata about the alarm.
|
||||
"""
|
||||
msg = msg or {}
|
||||
meta = meta or {}
|
||||
|
||||
centres = WidgetIO.find_widgets(NotificationCentre)
|
||||
kind = self._banner_kind_from_severity(msg.get("severity", 0))
|
||||
|
||||
# Normalise the incoming info payload (can be ErrorInfo, dict or missing entirely)
|
||||
raw_info = msg.get("info")
|
||||
if isinstance(raw_info, dict):
|
||||
try:
|
||||
raw_info = ErrorInfo(**raw_info)
|
||||
except Exception:
|
||||
raw_info = None
|
||||
|
||||
notification_id = getattr(raw_info, "id", None) or uuid4().hex
|
||||
|
||||
# build title and body
|
||||
scan_id = meta.get("scan_id")
|
||||
scan_number = meta.get("scan_number")
|
||||
formatted_trace = self._err_util.format_traceback(msg.get("msg", ""))
|
||||
short_msg = self._err_util.parse_error_message(formatted_trace)
|
||||
title = msg.get("alarm_type", "Alarm")
|
||||
alarm_type = msg.get("alarm_type") or getattr(raw_info, "exception_type", None) or "Alarm"
|
||||
title = alarm_type
|
||||
if scan_number:
|
||||
title += f" - Scan #{scan_number}"
|
||||
body_text = short_msg
|
||||
# build detailed traceback
|
||||
sections: list[str] = []
|
||||
if scan_id:
|
||||
sections.extend(["-------- SCAN_ID --------\n", scan_id])
|
||||
sections.extend(["-------- TRACEBACK --------", formatted_trace])
|
||||
source = msg.get("source")
|
||||
if source:
|
||||
source_pretty = json.dumps(source, indent=4, default=str)
|
||||
sections.extend(["", "-------- SOURCE --------", source_pretty])
|
||||
detailed_trace = "\n".join(sections)
|
||||
|
||||
trace_text = getattr(raw_info, "error_message", None) or msg.get("msg") or ""
|
||||
compact_msg = getattr(raw_info, "compact_error_message", None)
|
||||
|
||||
# Prefer the compact message; fall back to parsing the traceback for a human‑readable snippet
|
||||
body_text = compact_msg or self._err_util.parse_error_message(trace_text)
|
||||
|
||||
# build detailed traceback for the expandable panel
|
||||
detailed_trace: str | None = None
|
||||
if trace_text:
|
||||
sections: list[str] = []
|
||||
if scan_id:
|
||||
sections.extend(["-------- SCAN_ID --------\n", scan_id])
|
||||
sections.extend(["-------- TRACEBACK --------", trace_text])
|
||||
detailed_trace = "\n".join(sections)
|
||||
|
||||
lifetime = 0 if kind == SeverityKind.MAJOR else 5_000
|
||||
|
||||
# generate one ID for all toasts of this event
|
||||
notification_id = uuid4().hex
|
||||
if notification_id in self._active_notifications:
|
||||
return # already posted
|
||||
# record this notification for future centres
|
||||
self._active_notifications[notification_id] = {
|
||||
"title": title,
|
||||
@@ -1059,9 +1126,8 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
lifetime_ms=lifetime,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
# broadcast any close or expire
|
||||
# broadcast close events (expiry is handled locally to keep history)
|
||||
toast.closed.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
|
||||
toast.expired.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_status(self, msg: dict, meta: dict) -> None:
|
||||
@@ -1086,6 +1152,13 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
Translate an integer severity (0/1/2) into a SeverityKind enum.
|
||||
Unknown values fall back to SeverityKind.WARNING.
|
||||
"""
|
||||
if isinstance(severity, SeverityKind):
|
||||
return severity
|
||||
if isinstance(severity, str):
|
||||
try:
|
||||
return SeverityKind(severity)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return SeverityKind[Alarms(severity).name] # e.g. WARNING → SeverityKind.WARNING
|
||||
except (ValueError, KeyError):
|
||||
@@ -1164,10 +1237,10 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
# ----- wiring ------------------------------------------------------------
|
||||
self._counter = 1
|
||||
self.info_btn.clicked.connect(lambda: self._post("info"))
|
||||
self.warning_btn.clicked.connect(lambda: self._post("warning"))
|
||||
self.minor_btn.clicked.connect(lambda: self._post("minor"))
|
||||
self.major_btn.clicked.connect(lambda: self._post("major"))
|
||||
self.info_btn.clicked.connect(lambda: self._post(SeverityKind.INFO))
|
||||
self.warning_btn.clicked.connect(lambda: self._post(SeverityKind.WARNING))
|
||||
self.minor_btn.clicked.connect(lambda: self._post(SeverityKind.MINOR))
|
||||
self.major_btn.clicked.connect(lambda: self._post(SeverityKind.MAJOR))
|
||||
# Raise buttons simulate alarms
|
||||
self.raise_warning_btn.clicked.connect(lambda: self._raise_error(Alarms.WARNING))
|
||||
self.raise_minor_btn.clicked.connect(lambda: self._raise_error(Alarms.MINOR))
|
||||
@@ -1183,30 +1256,28 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
indicator.hide_all_requested.connect(self.notification_centre.hide_all)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _post(self, kind):
|
||||
expire = 0 if kind == "error" else 5000
|
||||
trace = (
|
||||
'Traceback (most recent call last):\n File "<stdin>", line 1\nZeroDivisionError: 1/0'
|
||||
if kind == "error"
|
||||
else None
|
||||
)
|
||||
self.notification_centre.add_notification(
|
||||
title=f"{kind.capitalize()} #{self._counter}",
|
||||
body="Lorem ipsum dolor sit amet.",
|
||||
kind=SeverityKind(kind),
|
||||
lifetime_ms=expire,
|
||||
traceback=trace,
|
||||
)
|
||||
def _post(self, kind: SeverityKind):
|
||||
"""
|
||||
Send a simple notification through the broker (non-error case).
|
||||
"""
|
||||
msg = {
|
||||
"severity": kind.value, # handled by broker for SeverityKind
|
||||
"alarm_type": f"{kind.value.capitalize()}",
|
||||
"msg": f"{kind.value.capitalize()} #{self._counter}",
|
||||
}
|
||||
self.notification_broker.post_notification(msg, meta={})
|
||||
self._counter += 1
|
||||
|
||||
def _raise_error(self, severity):
|
||||
"""Simulate an error that would be caught by the notification broker."""
|
||||
self.notification_broker.client.connector.raise_alarm(
|
||||
severity=severity,
|
||||
alarm_type="ValueError",
|
||||
source={"device": "samx", "source": "async_file_writer"},
|
||||
msg=f"test alarm",
|
||||
metadata={"test": 1},
|
||||
info=ErrorInfo(
|
||||
id=uuid4().hex,
|
||||
exception_type="ValueError",
|
||||
error_message="An example error occurred in DemoWindowApp.",
|
||||
compact_error_message="An example error occurred.",
|
||||
),
|
||||
)
|
||||
|
||||
# this part is same as implemented in the BECMainWindow
|
||||
@@ -1225,6 +1296,7 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
def main(): # pragma: no cover
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
win = DemoWindow()
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -12,4 +12,4 @@ class BECWebLinksMixin:
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
|
||||
SCAN_PROGRESS_HEIGHT = 12 # px
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -201,8 +201,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_simple.show_remaining_time = False
|
||||
self._scan_progress_bar_simple.show_source_label = False
|
||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
self._scan_progress_bar_full = ScanProgressBar(self)
|
||||
self._scan_progress_hover = HoverWidget(
|
||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||
@@ -219,62 +219,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(separator)
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
|
||||
|
||||
# Set Size
|
||||
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
|
||||
|
||||
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
|
||||
|
||||
# Visibility logic
|
||||
self._scan_progress_bar_with_separator.hide()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
# Timer for hiding logic
|
||||
self._scan_progress_hide_timer = QTimer(self)
|
||||
self._scan_progress_hide_timer.setSingleShot(True)
|
||||
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
|
||||
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
|
||||
|
||||
# Show / hide behaviour
|
||||
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
|
||||
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
|
||||
|
||||
def _show_scan_progress_bar(self):
|
||||
if self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
if self._scan_progress_bar_with_separator.isVisible():
|
||||
return
|
||||
|
||||
# Make visible and reset width
|
||||
self._scan_progress_bar_with_separator.show()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
self._show_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._show_container_anim.setDuration(300)
|
||||
self._show_container_anim.setStartValue(0)
|
||||
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
|
||||
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
self._show_container_anim.start()
|
||||
|
||||
def _delay_hide_scan_progress_bar(self):
|
||||
"""Start the countdown to hide the scan progress bar."""
|
||||
if hasattr(self, "_scan_progress_hide_timer"):
|
||||
self._scan_progress_hide_timer.start()
|
||||
|
||||
def _animate_hide_scan_progress_bar(self):
|
||||
"""Shrink container to the right, then hide."""
|
||||
self._hide_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._hide_container_anim.setDuration(300)
|
||||
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
|
||||
self._hide_container_anim.setEndValue(0)
|
||||
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
|
||||
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
|
||||
self._hide_container_anim.start()
|
||||
|
||||
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
|
||||
"""
|
||||
Add a vertically centred separator to the status bar or just return it as a separate object.
|
||||
@@ -474,8 +420,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
# Timer cleanup
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
|
||||
########################################
|
||||
# Status bar widgets cleanup
|
||||
|
||||
@@ -85,7 +85,6 @@ class ScanMetadata(PydanticModelForm):
|
||||
def set_schema_from_scan(self, scan_name: str | None):
|
||||
self._scan_name = scan_name or ""
|
||||
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
||||
self.populate()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -268,6 +268,20 @@ class Heatmap(ImageBase):
|
||||
if show_config_label is None:
|
||||
show_config_label = self._image_config.show_config_label
|
||||
|
||||
def _device_key(device: HeatmapDeviceSignal | None) -> tuple[str | None, str | None]:
|
||||
return (device.name if device else None, device.entry if device else None)
|
||||
|
||||
prev_cfg = getattr(self, "_image_config", None)
|
||||
config_changed = False
|
||||
if prev_cfg and prev_cfg.x_device and prev_cfg.y_device and prev_cfg.z_device:
|
||||
config_changed = any(
|
||||
(
|
||||
_device_key(prev_cfg.x_device) != (x_name, x_entry),
|
||||
_device_key(prev_cfg.y_device) != (y_name, y_entry),
|
||||
_device_key(prev_cfg.z_device) != (z_name, z_entry),
|
||||
)
|
||||
)
|
||||
|
||||
self._image_config = HeatmapConfig(
|
||||
parent_id=self.gui_id,
|
||||
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
|
||||
@@ -282,7 +296,10 @@ class Heatmap(ImageBase):
|
||||
show_config_label=show_config_label,
|
||||
)
|
||||
self.color_map = color_map
|
||||
self.reload = reload
|
||||
self.reload = reload or config_changed
|
||||
if config_changed:
|
||||
self._grid_index = None
|
||||
self.main_image.clear()
|
||||
self.update_labels()
|
||||
|
||||
self._fetch_running_scan()
|
||||
@@ -603,55 +620,51 @@ class Heatmap(ImageBase):
|
||||
|
||||
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],
|
||||
)
|
||||
x_entry = self._image_config.x_device.entry
|
||||
y_entry = self._image_config.y_device.entry
|
||||
shape = (args[x_entry][-1], args[y_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
|
||||
elif self.reload:
|
||||
data.fill(np.nan)
|
||||
|
||||
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)
|
||||
slow_entry, fast_entry = (
|
||||
msg.request_inputs["arg_bundle"][0],
|
||||
msg.request_inputs["arg_bundle"][4],
|
||||
)
|
||||
|
||||
# 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])
|
||||
scan_pos = np.asarray(msg.info["positions"], dtype=float)
|
||||
relative = bool(msg.request_inputs["kwargs"].get("relative", False))
|
||||
|
||||
x_range = x_max - x_min
|
||||
y_range = y_max - y_min
|
||||
def _axis_column(entry: str) -> int:
|
||||
return 0 if entry == slow_entry else 1
|
||||
|
||||
pixel_size_x = x_range / (shape[0] - 1)
|
||||
pixel_size_y = y_range / (shape[1] - 1)
|
||||
def _axis_levels(entry: str, npts: int) -> np.ndarray:
|
||||
start, stop = args[entry][:2]
|
||||
if relative:
|
||||
origin = float(scan_pos[0, _axis_column(entry)] - start)
|
||||
return origin + np.linspace(start, stop, npts)
|
||||
return np.linspace(start, stop, npts)
|
||||
|
||||
x_levels = _axis_levels(x_entry, shape[0])
|
||||
y_levels = _axis_levels(y_entry, shape[1])
|
||||
|
||||
pixel_size_x = (
|
||||
float(x_levels[-1] - x_levels[0]) / max(shape[0] - 1, 1) if shape[0] > 1 else 1.0
|
||||
)
|
||||
pixel_size_y = (
|
||||
float(y_levels[-1] - y_levels[0]) / max(shape[1] - 1, 1) if shape[1] > 1 else 1.0
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
transform.scale(pixel_size_x, pixel_size_y)
|
||||
transform.translate(x_levels[0] / pixel_size_x - 0.5, y_levels[0] / pixel_size_y - 0.5)
|
||||
|
||||
# Fill the data array with the z values
|
||||
if self._grid_index is None or self.reload:
|
||||
@@ -659,7 +672,16 @@ class Heatmap(ImageBase):
|
||||
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]
|
||||
slow_i, fast_i = divmod(i, args[fast_entry][-1])
|
||||
if snaked and (slow_i % 2 == 1):
|
||||
fast_i = args[fast_entry][-1] - 1 - fast_i
|
||||
|
||||
if x_entry == fast_entry:
|
||||
x_i, y_i = fast_i, slow_i
|
||||
else:
|
||||
x_i, y_i = slow_i, fast_i
|
||||
|
||||
data[x_i, y_i] = z_data[i]
|
||||
self._grid_index = len(z_data)
|
||||
return data, transform
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
"""
|
||||
if self.config.source in ["custom", "history"]:
|
||||
self.setData(x, y)
|
||||
self.parent_item.request_dap_update.emit()
|
||||
else:
|
||||
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
|
||||
|
||||
|
||||
@@ -718,9 +718,9 @@ class Waveform(PlotBase):
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -809,7 +809,7 @@ class Waveform(PlotBase):
|
||||
# CREATE THE CURVE
|
||||
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
|
||||
|
||||
if dap is not None and source == "device":
|
||||
if dap is not None and curve.config.source in ("device", "history", "custom"):
|
||||
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
|
||||
|
||||
return curve
|
||||
@@ -826,11 +826,12 @@ class Waveform(PlotBase):
|
||||
**kwargs,
|
||||
) -> Curve:
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
Create a new DAP curve referencing the existing curve `device_label`, with the
|
||||
data processing model `dap_name`. DAP curves can be attached to curves that
|
||||
originate from live devices, history, or fully custom data sources.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
@@ -840,17 +841,22 @@ class Waveform(PlotBase):
|
||||
Curve: The new DAP curve.
|
||||
"""
|
||||
|
||||
# 1) Find the existing device curve by label
|
||||
# 1) Find the existing curve by label
|
||||
device_curve = self._find_curve_by_label(device_label)
|
||||
if not device_curve:
|
||||
raise ValueError(f"No existing curve found with label '{device_label}'.")
|
||||
if device_curve.config.source not in ("device", "history"):
|
||||
if device_curve.config.source not in ("device", "history", "custom"):
|
||||
raise ValueError(
|
||||
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
|
||||
f"Curve '{device_label}' is not compatible with DAP. "
|
||||
f"Only device, history, or custom curves support fitting."
|
||||
)
|
||||
|
||||
dev_name = device_curve.config.signal.name
|
||||
dev_entry = device_curve.config.signal.entry
|
||||
dev_name = getattr(getattr(device_curve.config, "signal", None), "name", None)
|
||||
dev_entry = getattr(getattr(device_curve.config, "signal", None), "entry", None)
|
||||
if dev_name is None:
|
||||
dev_name = device_label
|
||||
if dev_entry is None:
|
||||
dev_entry = "custom"
|
||||
|
||||
# 2) Build a label for the new DAP curve
|
||||
dap_label = f"{device_label}-{dap_name}"
|
||||
@@ -1514,7 +1520,7 @@ class Waveform(PlotBase):
|
||||
|
||||
self.request_dap_update.emit()
|
||||
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> bool:
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if the async signal is found in the BEC device manager.
|
||||
|
||||
@@ -1523,13 +1529,16 @@ class Waveform(PlotBase):
|
||||
signal(str): The entry of the async signal.
|
||||
|
||||
Returns:
|
||||
bool: True if the async signal is found, False otherwise.
|
||||
tuple[bool, str]: A tuple where the first element is True if the async signal is found (False otherwise),
|
||||
and the second element is the signal name (either the original signal or the storage_name for AsyncMultiSignal).
|
||||
"""
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals(
|
||||
["AsyncSignal", "AsyncMultiSignal"]
|
||||
)
|
||||
for entry_name, _, entry_data in bec_async_signals:
|
||||
if entry_name == name and entry_data.get("obj_name") == signal:
|
||||
return True
|
||||
return False
|
||||
return True, entry_data.get("storage_name")
|
||||
return False, signal
|
||||
|
||||
def _setup_async_curve(self, curve: Curve):
|
||||
"""
|
||||
@@ -1540,7 +1549,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
name = curve.config.signal.name
|
||||
signal = curve.config.signal.entry
|
||||
async_signal_found = self._check_async_signal_found(name, signal)
|
||||
async_signal_found, signal = self._check_async_signal_found(name, signal)
|
||||
|
||||
try:
|
||||
curve.clear_data()
|
||||
@@ -1622,6 +1631,9 @@ class Waveform(PlotBase):
|
||||
continue
|
||||
# Ensure we have numpy array for data_plot_y
|
||||
data_plot_y = np.asarray(data_plot_y)
|
||||
if data_plot_y.ndim == 0:
|
||||
# Convert scalars/0d arrays to 1d so len() and stacking work
|
||||
data_plot_y = data_plot_y.reshape(1)
|
||||
# Add
|
||||
if instruction == "add":
|
||||
if len(max_shape) > 1:
|
||||
@@ -2329,7 +2341,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Waveform Demo")
|
||||
self.resize(800, 600)
|
||||
self.resize(1200, 600)
|
||||
self.main_widget = QWidget(self)
|
||||
self.layout = QHBoxLayout(self.main_widget)
|
||||
self.setCentralWidget(self.main_widget)
|
||||
@@ -2341,8 +2353,31 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.waveform_side.plot(y_name="bpm3a", y_entry="bpm3a")
|
||||
|
||||
self.custom_waveform = Waveform(popups=True)
|
||||
self._populate_custom_curve_demo()
|
||||
|
||||
self.layout.addWidget(self.waveform_side)
|
||||
self.layout.addWidget(self.waveform_popup)
|
||||
self.layout.addWidget(self.custom_waveform)
|
||||
|
||||
def _populate_custom_curve_demo(self):
|
||||
"""
|
||||
Showcase how to attach a DAP fit to a fully custom curve.
|
||||
|
||||
The example generates a noisy Gaussian trace, plots it as custom data, and
|
||||
immediately adds a Gaussian model fit. When the widget is plugged into a
|
||||
running BEC instance, the fit curve will be requested like any other device
|
||||
signal. This keeps the example minimal while demonstrating the new workflow.
|
||||
"""
|
||||
x = np.linspace(-4, 4, 600)
|
||||
rng = np.random.default_rng(42)
|
||||
noise = rng.normal(loc=0, scale=0.05, size=x.size)
|
||||
amplitude = 3.5
|
||||
center = 0.5
|
||||
sigma = 0.8
|
||||
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise
|
||||
|
||||
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QTimer, Signal
|
||||
@@ -264,22 +265,28 @@ class ScanProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
if not "queue" in msg_content:
|
||||
return
|
||||
primary_queue_info = msg_content["queue"].get("primary", {}).get("info", [])
|
||||
if "primary" not in msg_content["queue"]:
|
||||
return
|
||||
if (primary_queue := msg_content.get("queue").get("primary")) is None:
|
||||
return
|
||||
if not isinstance(primary_queue, messages.ScanQueueStatus):
|
||||
return
|
||||
primary_queue_info = primary_queue.info
|
||||
if len(primary_queue_info) == 0:
|
||||
return
|
||||
scan_info = primary_queue_info[0]
|
||||
if scan_info is None:
|
||||
return
|
||||
if scan_info.get("status").lower() == "running" and self.task is None:
|
||||
if scan_info.status.lower() == "running" and self.task is None:
|
||||
self.task = ProgressTask(parent=self)
|
||||
self.progress_started.emit()
|
||||
|
||||
active_request_block = scan_info.get("active_request_block", {})
|
||||
active_request_block = scan_info.active_request_block
|
||||
if active_request_block is None:
|
||||
return
|
||||
|
||||
self.scan_number = active_request_block.get("scan_number")
|
||||
report_instructions = active_request_block.get("report_instructions", [])
|
||||
self.scan_number = active_request_block.scan_number
|
||||
report_instructions = active_request_block.report_instructions
|
||||
if not report_instructions:
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Qt, Signal, Slot
|
||||
@@ -145,7 +146,16 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
_metadata (dict): The metadata.
|
||||
"""
|
||||
# only show the primary queue for now
|
||||
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
|
||||
queues = content.get("queue", {})
|
||||
if not queues:
|
||||
self.reset_content()
|
||||
return
|
||||
primary_queue: messages.ScanQueueStatus | None = queues.get("primary")
|
||||
if not primary_queue:
|
||||
self.reset_content()
|
||||
return
|
||||
queue_info = primary_queue.info
|
||||
|
||||
self.table.setRowCount(len(queue_info))
|
||||
self.table.clearContents()
|
||||
|
||||
@@ -154,19 +164,19 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
return
|
||||
|
||||
for index, item in enumerate(queue_info):
|
||||
blocks = item.get("request_blocks", [])
|
||||
blocks = item.request_blocks
|
||||
scan_types = []
|
||||
scan_numbers = []
|
||||
scan_ids = []
|
||||
status = item.get("status", "")
|
||||
status = item.status
|
||||
for request_block in blocks:
|
||||
scan_type = request_block.get("content", {}).get("scan_type", "")
|
||||
scan_type = request_block.msg.scan_type
|
||||
if scan_type:
|
||||
scan_types.append(scan_type)
|
||||
scan_number = request_block.get("scan_number", "")
|
||||
scan_number = request_block.scan_number
|
||||
if scan_number:
|
||||
scan_numbers.append(str(scan_number))
|
||||
scan_id = request_block.get("scan_id", "")
|
||||
scan_id = request_block.scan_id
|
||||
if scan_id:
|
||||
scan_ids.append(scan_id)
|
||||
if scan_types:
|
||||
@@ -178,7 +188,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
self.set_row(index, scan_numbers, scan_types, status, scan_ids)
|
||||
busy = (
|
||||
False
|
||||
if all(item.get("status") in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
|
||||
if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
|
||||
else True
|
||||
)
|
||||
self.set_global_state("warning" if busy else "default")
|
||||
|
||||
@@ -55,7 +55,9 @@ 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._config_helper = ConfigHelper(
|
||||
self.client.connector, self.client._service_name, self.client.device_manager
|
||||
)
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.init_ui()
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 pydantic import ValidationError, field_validator
|
||||
from pydantic import field_validator
|
||||
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -65,7 +65,7 @@ class DeviceConfigDialog(BECWidget, QDialog):
|
||||
self._initial_config = {}
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._config_helper = config_helper or ConfigHelper(
|
||||
self.client.connector, self.client._service_name
|
||||
self.client.connector, self.client._service_name, self.client.device_manager
|
||||
)
|
||||
self._device = device
|
||||
self._action: Literal["update", "add"] = action
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.44.0"
|
||||
version = "2.45.13"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -13,13 +13,13 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client~=3.70", # needed for jupyter console
|
||||
"bec_ipython_client~=3.70", # needed for jupyter console
|
||||
"bec_lib~=3.70",
|
||||
"bec_qthemes~=0.7, >=0.7",
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"pyqtgraph==0.13.7",
|
||||
"PySide6==6.9.0",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
@@ -32,7 +32,6 @@ dependencies = [
|
||||
dev = [
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"isort~=5.13, >=5.13.2",
|
||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
|
||||
@@ -12,7 +12,7 @@ from bec_lib.scan_history import ScanHistory
|
||||
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
|
||||
|
||||
|
||||
def fake_redis_server(host, port):
|
||||
def fake_redis_server(host, port, **kwargs):
|
||||
redis = fakeredis.FakeRedis()
|
||||
return redis
|
||||
|
||||
|
||||
@@ -193,21 +193,6 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
|
||||
assert np.isclose(y, 5)
|
||||
|
||||
|
||||
def test_marker_positions_after_mouse_move(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
pos_in_view = QPointF(2, 5)
|
||||
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
|
||||
event_mock = [pos_in_scene]
|
||||
|
||||
crosshair.mouse_moved(event_mock)
|
||||
|
||||
marker = crosshair.marker_moved_1d["Curve 1"]
|
||||
marker_x, marker_y = marker.getData()
|
||||
assert marker_x == [2]
|
||||
assert marker_y == [5]
|
||||
|
||||
|
||||
def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_update_cycle(update_dialog, qtbot):
|
||||
"deviceClass": "TestDevice",
|
||||
"deviceConfig": {"param1": "val1"},
|
||||
"readoutPriority": "monitored",
|
||||
"description": None,
|
||||
"description": "",
|
||||
"readOnly": False,
|
||||
"softwareTrigger": False,
|
||||
"onFailure": "retry",
|
||||
|
||||
@@ -4,6 +4,7 @@ import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
|
||||
|
||||
@@ -125,12 +126,16 @@ def test_heatmap_get_image_data_unsupported_scan(heatmap_widget):
|
||||
|
||||
|
||||
def test_heatmap_get_grid_scan_image(heatmap_widget):
|
||||
x_levels = np.linspace(-5, 5, 10).tolist()
|
||||
y_levels = np.linspace(-5, 5, 10).tolist()
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={"positions": np.random.rand(100, 2).tolist()},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=True)
|
||||
},
|
||||
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
|
||||
)
|
||||
heatmap_widget._image_config = HeatmapConfig(
|
||||
@@ -145,6 +150,111 @@ def test_heatmap_get_grid_scan_image(heatmap_widget):
|
||||
assert sorted(np.asarray(img, dtype=int).flatten().tolist()) == list(range(100))
|
||||
|
||||
|
||||
def _grid_positions(
|
||||
*, slow_levels: list[float], fast_levels: list[float], snaked: bool, slow_is_col0: bool = True
|
||||
) -> list[list[float]]:
|
||||
positions: list[list[float]] = []
|
||||
for slow_i, slow_val in enumerate(slow_levels):
|
||||
row_fast = fast_levels if (not snaked or slow_i % 2 == 0) else list(reversed(fast_levels))
|
||||
for fast_val in row_fast:
|
||||
if slow_is_col0:
|
||||
positions.append([slow_val, fast_val])
|
||||
else:
|
||||
positions.append([fast_val, slow_val])
|
||||
return positions
|
||||
|
||||
|
||||
def test_heatmap_grid_scan_direction_and_snaking_x_fast(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",
|
||||
)
|
||||
|
||||
# x decreases (relative), y increases (relative), x is fast axis
|
||||
x0 = 10.0
|
||||
y0 = -3.0
|
||||
x_levels = (x0 + np.linspace(1.0, -1.0, 3)).tolist()
|
||||
y_levels = (y0 + np.linspace(-2.0, 2.0, 2)).tolist()
|
||||
snaked = True
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=y_levels, fast_levels=x_levels, snaked=snaked)
|
||||
},
|
||||
request_inputs={
|
||||
"arg_bundle": ["samy", -2.0, 2.0, 2, "samx", 1.0, -1.0, 3],
|
||||
"kwargs": {"snaked": snaked, "relative": True},
|
||||
},
|
||||
)
|
||||
|
||||
img, transform = heatmap_widget.get_grid_scan_image(list(range(6)), msg=scan_msg)
|
||||
|
||||
assert img.shape == (3, 2)
|
||||
assert img[0, 0] == 0 # first point: (x0,y0) in scan order
|
||||
assert img[2, 1] == 3 # second row first point due to snaking
|
||||
assert img[0, 1] == 5 # last point in second row
|
||||
|
||||
p0 = transform.map(QPointF(0.5, 0.5))
|
||||
p1 = transform.map(QPointF(2.5, 1.5))
|
||||
assert p0.x() == pytest.approx(x_levels[0])
|
||||
assert p0.y() == pytest.approx(y_levels[0])
|
||||
assert p1.x() == pytest.approx(x_levels[-1])
|
||||
assert p1.y() == pytest.approx(y_levels[-1])
|
||||
|
||||
|
||||
def test_heatmap_grid_scan_direction_and_snaking_y_fast(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",
|
||||
)
|
||||
|
||||
# x decreases (relative), y increases (relative), y is fast axis
|
||||
x0 = 1.5
|
||||
y0 = 22.0
|
||||
x_levels = (x0 + np.linspace(1.0, -1.0, 3)).tolist()
|
||||
y_levels = (y0 + np.linspace(-2.0, 2.0, 2)).tolist()
|
||||
snaked = True
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=snaked)
|
||||
},
|
||||
request_inputs={
|
||||
"arg_bundle": ["samx", 1.0, -1.0, 3, "samy", -2.0, 2.0, 2],
|
||||
"kwargs": {"snaked": snaked, "relative": True},
|
||||
},
|
||||
)
|
||||
|
||||
img, transform = heatmap_widget.get_grid_scan_image(list(range(6)), msg=scan_msg)
|
||||
|
||||
assert img.shape == (3, 2)
|
||||
assert img[0, 0] == 0
|
||||
# For y-fast scans, snaking reverses the y index on every odd x row.
|
||||
assert img[1, 1] == 2
|
||||
assert img[1, 0] == 3
|
||||
|
||||
p0 = transform.map(QPointF(0.5, 0.5))
|
||||
p1 = transform.map(QPointF(2.5, 1.5))
|
||||
assert p0.x() == pytest.approx(x_levels[0])
|
||||
assert p0.y() == pytest.approx(y_levels[0])
|
||||
assert p1.x() == pytest.approx(x_levels[-1])
|
||||
assert p1.y() == pytest.approx(y_levels[-1])
|
||||
|
||||
|
||||
def test_heatmap_get_step_scan_image(heatmap_widget):
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
@@ -193,12 +303,16 @@ def test_heatmap_update_plot(heatmap_widget):
|
||||
color_map="viridis",
|
||||
)
|
||||
heatmap_widget.scan_item = create_dummy_scan_item()
|
||||
x_levels = np.linspace(-5, 5, 10).tolist()
|
||||
y_levels = np.linspace(-5, 5, 10).tolist()
|
||||
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()},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=True)
|
||||
},
|
||||
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:
|
||||
|
||||
@@ -191,51 +191,10 @@ def test_bec_weblinks(monkeypatch):
|
||||
assert opened_urls == [
|
||||
"https://beamline-experiment-control.readthedocs.io/en/latest/",
|
||||
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
|
||||
"https://gitlab.psi.ch/groups/bec/-/issues/",
|
||||
"https://github.com/bec-project/bec_widgets/issues",
|
||||
]
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for scan‑progress bar animations
|
||||
|
||||
|
||||
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
|
||||
"""
|
||||
_show_scan_progress_bar should animate the container's maximumWidth
|
||||
from 0 to the configured target width.
|
||||
"""
|
||||
container = bec_main_window._scan_progress_bar_with_separator
|
||||
|
||||
# Pre‑condition: collapsed
|
||||
assert container.maximumWidth() == 0
|
||||
|
||||
bec_main_window._show_scan_progress_bar()
|
||||
|
||||
target = bec_main_window._scan_progress_bar_target_width
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
|
||||
|
||||
assert container.maximumWidth() == target
|
||||
|
||||
|
||||
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
|
||||
"""
|
||||
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
|
||||
"""
|
||||
container = bec_main_window._scan_progress_bar_with_separator
|
||||
|
||||
# First expand it
|
||||
bec_main_window._show_scan_progress_bar()
|
||||
target = bec_main_window._scan_progress_bar_target_width
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
|
||||
|
||||
# Trigger hide animation
|
||||
bec_main_window._animate_hide_scan_progress_bar()
|
||||
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
|
||||
|
||||
assert container.maximumWidth() == 0
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for hover widget and tooltip behaviour
|
||||
|
||||
|
||||
@@ -25,6 +25,30 @@ def scan_progressbar(qtbot, mocked_client):
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_message():
|
||||
return messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {"file_suffix": None, "file_directory": None},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
)
|
||||
|
||||
|
||||
def test_progress_task_basic():
|
||||
"""percentage, remaining, and formatted time helpers behave as expected."""
|
||||
task = ProgressTask(parent=None, value=50, max_value=100, done=False)
|
||||
@@ -167,7 +191,9 @@ def test_progressbar_queue_update(scan_progressbar):
|
||||
"""
|
||||
Test that an empty queue update does not change the progress source.
|
||||
"""
|
||||
msg = messages.ScanQueueStatusMessage(queue={"primary": {"info": [], "status": "RUNNING"}})
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
queue={"primary": messages.ScanQueueStatus(info=[], status="RUNNING")}
|
||||
)
|
||||
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
|
||||
scan_progressbar.on_queue_update(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
@@ -175,50 +201,37 @@ def test_progressbar_queue_update(scan_progressbar):
|
||||
mock_set_source.assert_not_called()
|
||||
|
||||
|
||||
def test_progressbar_queue_update_with_scan(scan_progressbar):
|
||||
def test_progressbar_queue_update_with_scan(scan_progressbar, scan_message):
|
||||
"""
|
||||
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
|
||||
"""
|
||||
request_block = messages.RequestBlock(
|
||||
msg=scan_message,
|
||||
RID="some-rid",
|
||||
scan_motors=["samx"],
|
||||
readout_priority={"monitored": ["samx"]},
|
||||
is_scan=True,
|
||||
scan_number=1,
|
||||
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
|
||||
report_instructions=[{"scan_progress": 20}],
|
||||
)
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
metadata={},
|
||||
queue={
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
"status": "RUNNING",
|
||||
"active_request_block": {
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"scan_number": 1,
|
||||
"report_instructions": [{"scan_progress": 20}],
|
||||
},
|
||||
}
|
||||
"primary": messages.ScanQueueStatus(
|
||||
info=[
|
||||
messages.QueueInfoEntry(
|
||||
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
status="RUNNING",
|
||||
active_request_block=request_block,
|
||||
is_scan=[True],
|
||||
request_blocks=[request_block],
|
||||
scan_number=[1],
|
||||
)
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
status="RUNNING",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -229,50 +242,37 @@ def test_progressbar_queue_update_with_scan(scan_progressbar):
|
||||
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
|
||||
|
||||
|
||||
def test_progressbar_queue_update_with_device(scan_progressbar):
|
||||
def test_progressbar_queue_update_with_device(scan_progressbar, scan_message):
|
||||
"""
|
||||
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
|
||||
"""
|
||||
request_block = messages.RequestBlock(
|
||||
msg=scan_message,
|
||||
RID="some-rid",
|
||||
scan_motors=["samx"],
|
||||
readout_priority={"monitored": ["samx"]},
|
||||
is_scan=True,
|
||||
scan_number=1,
|
||||
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
|
||||
report_instructions=[{"device_progress": ["samx"]}],
|
||||
)
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
metadata={},
|
||||
queue={
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
"status": "RUNNING",
|
||||
"active_request_block": {
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"scan_number": 1,
|
||||
"report_instructions": [{"device_progress": ["samx"]}],
|
||||
},
|
||||
}
|
||||
"primary": messages.ScanQueueStatus(
|
||||
info=[
|
||||
messages.QueueInfoEntry(
|
||||
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
status="RUNNING",
|
||||
active_request_block=request_block,
|
||||
is_scan=[True],
|
||||
request_blocks=[request_block],
|
||||
scan_number=[1],
|
||||
)
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
status="RUNNING",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -283,49 +283,36 @@ def test_progressbar_queue_update_with_device(scan_progressbar):
|
||||
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
|
||||
|
||||
|
||||
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar):
|
||||
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar, scan_message):
|
||||
"""
|
||||
Test that a queue update with neither scan nor device does not change the progress source.
|
||||
"""
|
||||
request_block = messages.RequestBlock(
|
||||
msg=scan_message,
|
||||
RID="some-rid",
|
||||
scan_motors=["samx"],
|
||||
readout_priority={"monitored": ["samx"]},
|
||||
is_scan=True,
|
||||
scan_number=1,
|
||||
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
|
||||
)
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
metadata={},
|
||||
queue={
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
"status": "RUNNING",
|
||||
"active_request_block": {
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"scan_number": 1,
|
||||
},
|
||||
}
|
||||
"primary": messages.ScanQueueStatus(
|
||||
info=[
|
||||
messages.QueueInfoEntry(
|
||||
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
status="RUNNING",
|
||||
active_request_block=request_block,
|
||||
is_scan=[True],
|
||||
request_blocks=[request_block],
|
||||
scan_number=[1],
|
||||
)
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
status="RUNNING",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -479,6 +479,43 @@ def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Ensure that custom curves can also serve as parents for DAP fits.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
x = np.linspace(-1, 1, 50)
|
||||
y = np.sin(x)
|
||||
custom_curve = wf.plot(x=x, y=y, label="custom-curve")
|
||||
|
||||
dap_curve = wf.add_dap_curve(device_label=custom_curve.name(), dap_name="GaussianModel")
|
||||
assert dap_curve.config.source == "dap"
|
||||
assert dap_curve.config.parent_label == custom_curve.name()
|
||||
assert dap_curve.config.signal.name == custom_curve.name()
|
||||
assert dap_curve.config.signal.entry == "custom"
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
with qtbot.waitSignal(wf.request_dap_update):
|
||||
c.set_data([7, 8, 9], [10, 11, 12])
|
||||
|
||||
|
||||
def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Supplying the `dap` kwarg when plotting custom data should auto-create the fit curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
curve = wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-inline", dap="GaussianModel")
|
||||
|
||||
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
||||
assert dap_curve is not None
|
||||
assert dap_curve.config.parent_label == curve.name()
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
|
||||
|
||||
Reference in New Issue
Block a user