1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-14 04:30:54 +02:00

Compare commits

...

21 Commits

Author SHA1 Message Date
semantic-release
ca6f355aac 2.45.12
Automatically generated by python-semantic-release
2025-12-16 12:37:06 +00:00
d876ca72bc fix(heatmap): more robust logic for fast and slow axis in grid scan 2025-12-16 13:36:17 +01:00
e0fd97616d fix(heatmap): flush image if config changes during scan 2025-12-16 13:36:17 +01:00
6af8a5cbfe fix(heatmap): grid scan image correctly map to scan positions 2025-12-16 13:36:17 +01:00
semantic-release
944e2cedf8 2.45.11
Automatically generated by python-semantic-release
2025-12-15 14:48:20 +00:00
cd11a6cce3 fix(waveform): support for AsyncMultiSignal 2025-12-15 15:47:26 +01:00
semantic-release
c98106e594 2.45.10
Automatically generated by python-semantic-release
2025-12-10 11:21:40 +00:00
04f1ff4fe7 fix(devices): minor fix to comply with new config helper in bec_lib 2025-12-10 12:20:48 +01:00
semantic-release
45ed92494c 2.45.9
Automatically generated by python-semantic-release
2025-12-09 14:21:27 +00:00
5fc96bd299 fix(rpc): add expiration to GUI registry state updates 2025-12-09 15:20:42 +01:00
semantic-release
1ad5df57fe 2.45.8
Automatically generated by python-semantic-release
2025-12-08 14:36:13 +00:00
440e778162 fix(notification_banner): backwards compatibility to push messages from Broker to Centre as dict 2025-12-08 15:35:23 +01:00
fdeb8fcb0f fix(notification_banner): formatted error messages fetched directly from BECMessage; do not repreat notifications ids 2025-12-08 15:35:23 +01:00
5c90983dd4 fix(notification_banner): better contrast in light mode 2025-12-08 15:35:23 +01:00
4171de1e45 fix(notification_banner): expired messages are hidden in notification center but still accessible 2025-12-08 15:35:23 +01:00
semantic-release
f12339e6f9 2.45.7
Automatically generated by python-semantic-release
2025-12-08 14:04:02 +00:00
ce8e5f0bec fix: handle none in literal combobox 2025-12-08 15:03:17 +01:00
semantic-release
7ea9ab5175 2.45.6
Automatically generated by python-semantic-release
2025-11-27 09:43:46 +00:00
b72f0dc6e8 fix(curve): update dap curves if data are set manually 2025-11-27 10:42:58 +01:00
semantic-release
cb9d429884 2.45.5
Automatically generated by python-semantic-release
2025-11-26 13:25:28 +00:00
0a80bd0a92 fix: remove ghost widgets in scan metadata 2025-11-26 14:24:36 +01:00
15 changed files with 471 additions and 130 deletions

View File

@@ -1,6 +1,86 @@
# CHANGELOG
## 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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 humanreadable 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())

View File

@@ -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

View File

@@ -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

View File

@@ -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.")

View File

@@ -1520,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.
@@ -1529,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):
"""
@@ -1546,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()

View File

@@ -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()

View File

@@ -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

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.45.4"
version = "2.45.12"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [

View File

@@ -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",

View File

@@ -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:

View File

@@ -496,6 +496,13 @@ def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
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.