fix(progress): scan progress reset on_scan_status in unified backend

This commit is contained in:
2026-06-07 10:52:59 +02:00
committed by Jan Wyzula
parent e547ec71ae
commit 3d93cf2f01
7 changed files with 274 additions and 27 deletions
@@ -14,6 +14,7 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
WARNING = "warning"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@@ -84,7 +85,8 @@ class BECProgressBar(BECWidget, QWidget):
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.PAUSED: accent_colors.highlight,
ProgressState.WARNING: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
@@ -128,7 +130,8 @@ class BECProgressBar(BECWidget, QWidget):
accent_colors = get_accent_colors()
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.PAUSED: accent_colors.highlight,
ProgressState.WARNING: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
@@ -176,7 +179,11 @@ class BECProgressBar(BECWidget, QWidget):
if self._enable_dynamic_stylesheet and self._value < previous_value:
self._chunk_radius = None
# Update state automatically unless paused or interrupted
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
if self._state not in (
ProgressState.PAUSED,
ProgressState.WARNING,
ProgressState.INTERRUPTED,
):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
@@ -358,12 +365,15 @@ class BECProgressBar(BECWidget, QWidget):
if chunk_radius != self._chunk_radius:
self._chunk_radius = chunk_radius
self._setup_style_sheet(chunk_radius=chunk_radius)
self._apply_state_palette()
def _apply_state_style(self) -> None:
if self._chunk_radius is None:
self._chunk_radius = self._current_chunk_radius()
self._setup_style_sheet(chunk_radius=self._chunk_radius)
self._apply_state_palette()
def _apply_state_palette(self) -> None:
color = self._state_colors[self._current_visual_state()]
palette = self.progressbar.palette()
palette.setColor(QPalette.ColorRole.Highlight, color)
@@ -393,7 +403,7 @@ class BECProgressBar(BECWidget, QWidget):
return min(target_radius, max(1, int(fill_width / 2)))
def _current_visual_state(self) -> ProgressState:
if self._state in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
if self._state in (ProgressState.PAUSED, ProgressState.WARNING, ProgressState.INTERRUPTED):
return self._state
if self._state == ProgressState.COMPLETED or self._value >= self._maximum:
return ProgressState.COMPLETED
@@ -8,6 +8,8 @@ import numpy as np
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QObject, QTimer, Signal
from bec_widgets.utils.error_popups import SafeSlot
@dataclass(frozen=True)
class ProgressSnapshot:
@@ -130,6 +132,8 @@ class BECProgressTracker(QObject):
self.scan_number: int | None = None
self._active_scan_id: str | None = None
self._active_rid: str | None = None
self._last_reset_scan_id: str | None = None
self._last_progress_scan_id: str | None = None
def start(self) -> None:
if self._connected:
@@ -137,6 +141,9 @@ class BECProgressTracker(QObject):
self.bec_dispatcher.connect_slot(
self.process_progress_message, MessageEndpoints.scan_progress()
)
self.bec_dispatcher.connect_slot(
self.process_scan_status_message, MessageEndpoints.scan_status()
)
self._connected = True
def _start_task(self, scan_id: str | None, rid: str | None = None) -> None:
@@ -183,6 +190,7 @@ class BECProgressTracker(QObject):
)
)
@SafeSlot(dict, dict)
def process_progress_message(
self, msg_content: dict, metadata: dict
) -> ProgressSnapshot | None:
@@ -197,6 +205,8 @@ class BECProgressTracker(QObject):
scan_number = metadata.get("scan_number")
if scan_number is not None:
self.scan_number = scan_number
if scan_id is not None:
self._last_progress_scan_id = scan_id
is_new_scan = False
previous_scan_id = self._active_scan_id
previous_rid = self._active_rid
@@ -235,10 +245,44 @@ class BECProgressTracker(QObject):
self.clear_task()
return snapshot
@SafeSlot(dict, dict)
def process_scan_status_message(
self, msg_content: dict, metadata: dict
) -> ProgressSnapshot | None:
if msg_content.get("status") != "open":
return None
scan_id = msg_content.get("scan_id") or metadata.get("scan_id") or metadata.get("RID")
if scan_id is None or scan_id == self._last_reset_scan_id:
return None
if scan_id == self._last_progress_scan_id:
self._last_reset_scan_id = scan_id
return None
self.clear_task(emit_finished=False)
self._last_reset_scan_id = scan_id
self.scan_number = msg_content.get("scan_number")
snapshot = ProgressSnapshot(
value=0,
max_value=100,
done=False,
status="open",
scan_id=scan_id,
scan_number=self.scan_number,
rid=metadata.get("RID"),
is_new_scan=True,
)
self.progress_updated.emit(snapshot)
return snapshot
def cleanup(self) -> None:
self.clear_task(emit_finished=False)
if self._connected:
self.bec_dispatcher.disconnect_slot(
self.process_progress_message, MessageEndpoints.scan_progress()
)
self.bec_dispatcher.disconnect_slot(
self.process_scan_status_message, MessageEndpoints.scan_status()
)
self._connected = False
self._last_reset_scan_id = None
self._last_progress_scan_id = None
@@ -17,10 +17,10 @@ logger = bec_logger.logger
BEC_STATUS_TO_PROGRESS_STATE = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.INTERRUPTED,
"halted": ProgressState.PAUSED,
"aborted": ProgressState.WARNING,
"halted": ProgressState.INTERRUPTED,
"closed": ProgressState.COMPLETED,
"user_completed": ProgressState.PAUSED,
"user_completed": ProgressState.COMPLETED,
}
@@ -87,12 +87,15 @@ class ScanProgressBar(BECWidget, QWidget):
def _on_progress_snapshot(self, snapshot: ProgressSnapshot):
self.update_labels()
if snapshot.is_new_scan and self.progress_tracker.task is None:
self.ui.elapsed_time_label.setText("00:00:00")
self.ui.remaining_time_label.setText("00:00:00")
self.update_source_label()
self.progressbar.set_maximum(snapshot.max_value)
self.progressbar.set_value(snapshot.value)
self.progressbar.state = BEC_STATUS_TO_PROGRESS_STATE.get(
snapshot.status.lower(), ProgressState.NORMAL
)
self.progressbar.set_value(snapshot.value)
@SafeProperty(bool)
def show_elapsed_time(self):
+40
View File
@@ -172,3 +172,43 @@ def test_progressbar_state_setter(progressbar):
"""Setting .state reflects internally."""
progressbar.state = ProgressState.PAUSED
assert progressbar.state is ProgressState.PAUSED
def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar):
assert (
progressbar._state_colors[ProgressState.PAUSED]
!= progressbar._state_colors[ProgressState.WARNING]
)
assert (
progressbar._state_colors[ProgressState.WARNING]
!= progressbar._state_colors[ProgressState.INTERRUPTED]
)
progressbar.state = ProgressState.WARNING
progressbar.set_value(50)
assert progressbar.state is ProgressState.WARNING
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.WARNING]
)
def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar):
assert (
progressbar._state_colors[ProgressState.PAUSED]
!= progressbar._state_colors[ProgressState.WARNING]
)
assert (
progressbar._state_colors[ProgressState.WARNING]
!= progressbar._state_colors[ProgressState.INTERRUPTED]
)
progressbar.state = ProgressState.WARNING
progressbar.set_value(50)
assert progressbar.state is ProgressState.WARNING
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.WARNING]
)
+91 -4
View File
@@ -16,9 +16,10 @@ def test_tracker_subscribes_to_scan_progress_immediately():
tracker.start()
dispatcher.connect_slot.assert_called_once_with(
tracker.process_progress_message, MessageEndpoints.scan_progress()
)
assert dispatcher.connect_slot.call_args_list == [
mock.call(tracker.process_progress_message, MessageEndpoints.scan_progress()),
mock.call(tracker.process_scan_status_message, MessageEndpoints.scan_status()),
]
tracker.cleanup()
@@ -49,12 +50,98 @@ def test_tracker_switches_sources_idempotently():
tracker.start()
tracker.start()
assert dispatcher.connect_slot.call_count == 1
assert dispatcher.connect_slot.call_count == 2
assert dispatcher.disconnect_slot.call_count == 0
tracker.cleanup()
def test_tracker_resets_progress_on_new_open_scan_status():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is not None
assert snapshot.value == 0
assert snapshot.max_value == 100
assert snapshot.status == "open"
assert snapshot.scan_id == "scan-1"
assert snapshot.scan_number == 7
assert snapshot.is_new_scan is True
assert tracker.task is None
assert tracker.scan_number == 7
assert snapshots[-1] == snapshot
tracker.cleanup()
def test_tracker_ignores_duplicate_open_scan_status():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {})
tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {})
assert len(snapshots) == 1
tracker.cleanup()
def test_tracker_ignores_open_scan_status_after_progress_for_same_scan():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 3, "max_value": 10}, {"scan_id": "scan-1", "RID": "rid-1", "status": "open"}
)
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is None
assert len(snapshots) == 1
assert snapshots[-1].value == 3
assert tracker.task is not None
assert tracker.task.value == 3
tracker.cleanup()
def test_tracker_ignores_open_scan_status_after_done_progress_for_same_scan():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 10, "max_value": 10, "done": True},
{"scan_id": "scan-1", "RID": "rid-1", "status": "closed"},
)
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is None
assert len(snapshots) == 1
assert snapshots[-1].value == 10
assert tracker.task is None
tracker.cleanup()
def test_tracker_marks_new_scan_only_when_rid_changes():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
+30 -10
View File
@@ -1,6 +1,6 @@
# pylint: disable=missing-function-docstring, missing-module-docstring
from unittest.mock import MagicMock
from unittest.mock import MagicMock, call
import pytest
from bec_lib.endpoints import MessageEndpoints
@@ -77,11 +77,14 @@ def test_set_update_to_scan(ring_widget):
ring_widget.set_update("scan")
assert ring_widget.config.mode == "scan"
# Verify that connect_slot was called
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.progress_tracker.process_progress_message
assert "scan_progress" in str(call_args[0][1])
assert ring_widget.bec_dispatcher.connect_slot.call_args_list == [
call(
ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress()
),
call(
ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status()
),
]
def test_set_update_from_scan_to_manual(ring_widget):
@@ -98,10 +101,14 @@ def test_set_update_from_scan_to_manual(ring_widget):
assert ring_widget.config.mode == "manual"
assert ring_widget.registered_slot is None
ring_widget.bec_dispatcher.disconnect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.disconnect_slot.call_args
assert call_args[0][0] == ring_widget.progress_tracker.process_progress_message
assert call_args[0][1] == MessageEndpoints.scan_progress()
assert ring_widget.bec_dispatcher.disconnect_slot.call_args_list == [
call(
ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress()
),
call(
ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status()
),
]
def test_set_update_to_device(ring_widget_with_device):
@@ -594,6 +601,19 @@ def test_scan_progress_updates_value(ring_widget):
assert ring_widget.config.value == 42
def test_scan_status_open_resets_scan_progress_value(ring_widget):
ring_widget.set_min_max_values(0, 200)
ring_widget.set_value(80)
ring_widget.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert ring_widget.config.min_value == 0
assert ring_widget.config.max_value == 100
assert ring_widget.config.value == 0
def test_scan_progress_updates_min_max_on_new_rid(ring_widget):
msg = {"value": 50, "max_value": 200}
meta = {"RID": "new_rid"}
+48 -5
View File
@@ -2,6 +2,7 @@ from unittest import mock
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
@@ -115,15 +116,57 @@ def test_progress_update(qtbot, scan_progressbar):
assert bar.state is ProgressState.NORMAL
def test_scan_status_open_resets_progress_before_first_progress_update(scan_progressbar):
scan_progressbar.progressbar.set_maximum(50)
scan_progressbar.progressbar.set_value(25)
scan_progressbar.progressbar.state = ProgressState.INTERRUPTED
scan_progressbar.ui.elapsed_time_label.setText("00:00:12")
scan_progressbar.ui.remaining_time_label.setText("00:00:34")
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {"RID": "rid-1"}
)
assert scan_progressbar.progressbar._user_value == 0
assert scan_progressbar.progressbar._user_maximum == 100
assert scan_progressbar.progressbar.state is ProgressState.NORMAL
assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:00"
assert scan_progressbar.ui.remaining_time_label.text() == "00:00:00"
assert scan_progressbar.ui.source_label.text() == "Scan 7"
def test_scan_status_open_reset_ignores_same_scan(scan_progressbar):
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "open"}, {}
)
scan_progressbar.progressbar.set_value(20)
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "open"}, {}
)
assert scan_progressbar.progressbar._user_value == 20
def test_scan_status_non_open_does_not_reset_progress(scan_progressbar):
scan_progressbar.progressbar.set_value(25)
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "closed"}, {}
)
assert scan_progressbar.progressbar._user_value == 25
@pytest.mark.parametrize(
"status, value, max_val, expected_state",
[
("open", 10, 100, ProgressState.NORMAL),
("paused", 25, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.INTERRUPTED),
("halted", 40, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.WARNING),
("halted", 40, 100, ProgressState.INTERRUPTED),
("closed", 100, 100, ProgressState.COMPLETED),
("user_completed", 40, 100, ProgressState.PAUSED),
("user_completed", 40, 100, ProgressState.COMPLETED),
("UNKNOWN", 10, 100, ProgressState.NORMAL),
],
)
@@ -151,7 +194,7 @@ def test_aborted_done_scan_keeps_partial_progress(scan_progressbar):
assert scan_progressbar.progressbar._user_value == 4
assert scan_progressbar.progressbar._user_maximum == 10
assert scan_progressbar.progressbar.state is ProgressState.INTERRUPTED
assert scan_progressbar.progressbar.state is ProgressState.WARNING
assert scan_progressbar.progress_tracker.task is None
@@ -197,7 +240,7 @@ def test_cleanup_disconnects_active_scan_subscription(scan_progressbar, monkeypa
):
ScanProgressBar.cleanup(scan_progressbar)
assert len(disconnect_calls) == 1
assert disconnect_calls == [MessageEndpoints.scan_progress(), MessageEndpoints.scan_status()]
assert scan_progressbar.progress_tracker._connected is False
close_mock.assert_not_called()
delete_later_mock.assert_not_called()