Skip to content

Commit 95ca814

Browse files
committed
feat(progress): progress is tracked from bec; unified progress backend
1 parent cd150c0 commit 95ca814

10 files changed

Lines changed: 426 additions & 948 deletions

File tree

bec_widgets/widgets/containers/main_window/main_window.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ def _add_scan_progress_bar(self):
209209
self._scan_progress_bar_simple.progressbar.label_template = ""
210210
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
211211
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
212+
# This one do not need dynamic styling on hover ScanProgressBar since user will hover on it probably later, when progress bar is big enough
212213
self._scan_progress_bar_full = ScanProgressBar(
213214
self, rpc_exposed=False, rpc_passthrough_children=False, enable_dynamic_stylesheet=False
214215
)
@@ -237,16 +238,16 @@ def _add_separator(self, separate_object: bool = False) -> QWidget | None:
237238

238239
# The actual line
239240
line = QFrame()
240-
line.setFrameShape(QFrame.VLine)
241-
line.setFrameShadow(QFrame.Sunken)
241+
line.setFrameShape(QFrame.Shape.VLine)
242+
line.setFrameShadow(QFrame.Shadow.Sunken)
242243
line.setFixedHeight(status_bar.sizeHint().height() - 2)
243244

244245
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
245246
wrapper = QWidget()
246247
vbox = QVBoxLayout(wrapper)
247248
vbox.setContentsMargins(0, 0, 0, 0)
248249
vbox.addStretch()
249-
vbox.addWidget(line, alignment=Qt.AlignHCenter)
250+
vbox.addWidget(line, alignment=Qt.AlignmentFlag.AlignHCenter)
250251
vbox.addStretch()
251252
wrapper.setFixedWidth(line.sizeHint().width())
252253

bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@ class ProgressState(Enum):
2020
@classmethod
2121
def from_bec_status(cls, status: str) -> "ProgressState":
2222
"""
23-
Map a BEC status string (open, paused, aborted, halted, closed)
23+
Map a BEC status string (open, paused, aborted, halt/halted, closed, user_completed)
2424
to the corresponding ProgressState.
2525
Any unknown status falls back to NORMAL.
2626
"""
2727
mapping = {
2828
"open": cls.NORMAL,
2929
"paused": cls.PAUSED,
3030
"aborted": cls.INTERRUPTED,
31+
"halt": cls.PAUSED,
3132
"halted": cls.PAUSED,
3233
"closed": cls.COMPLETED,
34+
"user_completed": cls.PAUSED,
3335
}
3436
return mapping.get(status.lower(), cls.NORMAL)
3537

@@ -104,9 +106,6 @@ def __init__(
104106
self.progressbar.setMinimumHeight(0)
105107
self.progressbar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
106108

107-
# Backwards-compatible alias used by existing tests and downstream code.
108-
self.center_label = self.progressbar
109-
110109
self._layout = QVBoxLayout(self)
111110
self._layout.setContentsMargins(self._padding_left_right, 0, self._padding_left_right, 0)
112111
self._layout.setSpacing(0)
@@ -339,6 +338,7 @@ def _sync_progressbar(self) -> None:
339338

340339
def _setup_style_sheet(self, *, chunk_radius: int) -> None:
341340
radius = int(round(self._corner_radius))
341+
chunk_color = self._state_colors[self._current_visual_state()].name()
342342
self.progressbar.setStyleSheet(f"""
343343
QProgressBar {{
344344
background-color: palette(mid);
@@ -348,7 +348,7 @@ def _setup_style_sheet(self, *, chunk_radius: int) -> None:
348348
text-align: center;
349349
}}
350350
QProgressBar::chunk {{
351-
background-color: palette(highlight);
351+
background-color: {chunk_color};
352352
border-radius: {chunk_radius}px;
353353
}}
354354
""")
@@ -377,6 +377,11 @@ def _initial_chunk_radius(self) -> int:
377377
return 0 if self._enable_dynamic_stylesheet else self._target_chunk_radius()
378378

379379
def _calculate_chunk_radius(self, target_radius: int) -> int:
380+
"""
381+
This whole chunk logic is to calculater radius based on the current size.
382+
If the radius is smaller than size of the progressbar it is just not applied.
383+
The chunk stylesheet logic is smoothing it as much as possible.
384+
"""
380385
if target_radius <= 0 or self._maximum <= 0:
381386
return 0
382387
fill_width = self.progressbar.width() * min(1.0, max(0.0, self._value / self._maximum))
@@ -385,6 +390,16 @@ def _calculate_chunk_radius(self, target_radius: int) -> int:
385390
return min(target_radius, max(1, int(fill_width / 2)))
386391

387392
def _apply_state_style(self) -> None:
393+
chunk_radius = self._chunk_radius
394+
if chunk_radius is None:
395+
target_radius = self._target_chunk_radius()
396+
chunk_radius = (
397+
self._calculate_chunk_radius(target_radius)
398+
if self._enable_dynamic_stylesheet
399+
else target_radius
400+
)
401+
self._chunk_radius = chunk_radius
402+
self._setup_style_sheet(chunk_radius=chunk_radius)
388403
color = self._state_colors[self._current_visual_state()]
389404
palette = self.progressbar.palette()
390405
palette.setColor(QPalette.ColorRole.Highlight, color)
@@ -406,20 +421,23 @@ def _get_label(self) -> str:
406421
if __name__ == "__main__": # pragma: no cover
407422
app = QApplication(sys.argv)
408423

409-
progressBar = BECProgressBar()
410-
progressBar.show()
411-
progressBar.set_minimum(-100)
412-
progressBar.set_maximum(0)
424+
progress_bar = BECProgressBar()
425+
progress_bar.setWindowTitle("BEC Progress Bar")
426+
progress_bar.resize(360, 48)
427+
progress_bar.set_minimum(-100)
428+
progress_bar.set_maximum(0)
429+
progress_bar.set_value(-100)
430+
progress_bar.show()
413431

414432
# Example of setting values
415433
def update_progress():
416-
value = progressBar._user_value + 2.5
417-
if value > progressBar._user_maximum:
418-
value = -100 # progressBar._maximum / progressBar._upsampling_factor
419-
progressBar.set_value(value)
434+
value = progress_bar._user_value + 2.5
435+
if value > progress_bar._user_maximum:
436+
value = progress_bar._user_minimum
437+
progress_bar.set_value(value)
420438

421-
timer = QTimer()
439+
timer = QTimer(progress_bar)
422440
timer.timeout.connect(update_progress)
423-
timer.start(200) # Update every half second
441+
timer.start(200)
424442

425443
sys.exit(app.exec())
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from dataclasses import dataclass
5+
from typing import Literal
6+
7+
import numpy as np
8+
from bec_lib.endpoints import MessageEndpoints
9+
from qtpy.QtCore import QObject, QTimer, Signal
10+
11+
12+
@dataclass(frozen=True)
13+
class ProgressSnapshot:
14+
value: float
15+
max_value: float
16+
done: bool
17+
status: Literal["open", "paused", "aborted", "halt", "halted", "closed", "user_completed"]
18+
scan_id: str | None = None
19+
scan_number: int | None = None
20+
rid: str | None = None
21+
is_new_scan: bool = False
22+
23+
24+
class ProgressTask(QObject):
25+
"""
26+
Class to store progress information.
27+
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
28+
"""
29+
30+
def __init__(
31+
self, parent: QObject | None, value: float = 0, max_value: float = 0, done: bool = False
32+
):
33+
super().__init__(parent=parent)
34+
self.start_time = time.monotonic()
35+
self.done = done
36+
self.value = value
37+
self.max_value = max_value
38+
self._elapsed_time = 0
39+
40+
self.timer = QTimer(self)
41+
self.timer.timeout.connect(self.update_elapsed_time)
42+
self.timer.start(1000)
43+
44+
def update(self, value: float, max_value: float, done: bool = False):
45+
"""
46+
Update the progress.
47+
"""
48+
self.max_value = max_value
49+
self.done = done
50+
self.value = value
51+
if done:
52+
self.timer.stop()
53+
54+
def update_elapsed_time(self):
55+
"""
56+
Update the time estimates. This is called every second by a QTimer.
57+
"""
58+
self._elapsed_time = max(0.0, time.monotonic() - self.start_time)
59+
60+
@property
61+
def percentage(self) -> float:
62+
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
63+
if not self.max_value:
64+
return 0.0
65+
completed = (self.value / self.max_value) * 100.0
66+
completed = min(100.0, max(0.0, completed))
67+
return completed
68+
69+
@property
70+
def speed(self) -> float:
71+
"""Get the estimated speed in steps per second."""
72+
if self._elapsed_time == 0:
73+
return 0.0
74+
75+
return self.value / self._elapsed_time
76+
77+
@property
78+
def frequency(self) -> float:
79+
"""Get the estimated frequency in steps per second."""
80+
if self.speed == 0:
81+
return 0.0
82+
return 1 / self.speed
83+
84+
@property
85+
def time_elapsed(self) -> str:
86+
return self._format_time(int(self._elapsed_time))
87+
88+
@property
89+
def remaining(self) -> float:
90+
"""Get the estimated remaining steps."""
91+
if self.done:
92+
return 0.0
93+
remaining = self.max_value - self.value
94+
return remaining
95+
96+
@property
97+
def time_remaining(self) -> str:
98+
"""
99+
Get the estimated remaining time in the format HH:MM:SS.
100+
"""
101+
if self.done or not self.speed or not self.remaining:
102+
return self._format_time(0)
103+
estimate = int(np.round(self.remaining / self.speed))
104+
105+
return self._format_time(estimate)
106+
107+
@staticmethod
108+
def _format_time(seconds: float) -> str:
109+
"""
110+
Format the time in seconds to a string in the format HH:MM:SS.
111+
"""
112+
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
113+
114+
115+
class BECProgressTracker(QObject):
116+
"""
117+
Shared backend for BEC scan progress messages.
118+
"""
119+
120+
progress_started = Signal(object)
121+
progress_updated = Signal(object)
122+
progress_finished = Signal(object)
123+
progress_cleared = Signal()
124+
125+
def __init__(self, bec_dispatcher, parent: QObject | None = None):
126+
super().__init__(parent=parent)
127+
self.bec_dispatcher = bec_dispatcher
128+
self._connected = False
129+
self.task: ProgressTask | None = None
130+
self.scan_number: int | None = None
131+
self._active_scan_id: str | None = None
132+
self._active_rid: str | None = None
133+
134+
def start(self) -> None:
135+
if self._connected:
136+
return
137+
self.bec_dispatcher.connect_slot(
138+
self.process_progress_message, MessageEndpoints.scan_progress()
139+
)
140+
self._connected = True
141+
142+
def _start_task(self, scan_id: str | None, rid: str | None = None) -> None:
143+
if self.task is not None:
144+
self.task.timer.stop()
145+
self.task.deleteLater()
146+
self.task = ProgressTask(parent=self)
147+
self._active_scan_id = scan_id
148+
self._active_rid = rid
149+
self.progress_started.emit(
150+
ProgressSnapshot(
151+
value=0,
152+
max_value=100,
153+
done=False,
154+
status="open",
155+
scan_id=self._active_scan_id,
156+
scan_number=self.scan_number,
157+
rid=self._active_rid,
158+
)
159+
)
160+
161+
def clear_task(self, *, emit_finished: bool = True) -> None:
162+
if self.task is None:
163+
self._active_scan_id = None
164+
self._active_rid = None
165+
self.progress_cleared.emit()
166+
return
167+
self.task.timer.stop()
168+
self.task.deleteLater()
169+
self.task = None
170+
self._active_scan_id = None
171+
self._active_rid = None
172+
self.progress_cleared.emit()
173+
if emit_finished:
174+
self.progress_finished.emit(
175+
ProgressSnapshot(
176+
value=0,
177+
max_value=100,
178+
done=True,
179+
status="open",
180+
scan_id=self._active_scan_id,
181+
scan_number=self.scan_number,
182+
rid=self._active_rid,
183+
)
184+
)
185+
186+
def process_progress_message(
187+
self,
188+
msg_content: dict,
189+
metadata: dict,
190+
) -> ProgressSnapshot | None:
191+
done = msg_content.get("done", False)
192+
value = msg_content.get("value", 0)
193+
max_value = msg_content.get("max_value", 100)
194+
status: Literal[
195+
"open", "paused", "aborted", "halt", "halted", "closed", "user_completed"
196+
] = metadata.get("status", "open")
197+
scan_id = metadata.get("scan_id") or metadata.get("RID")
198+
rid = metadata.get("RID")
199+
scan_number = metadata.get("scan_number")
200+
if scan_number is not None:
201+
self.scan_number = scan_number
202+
is_new_scan = False
203+
previous_scan_id = self._active_scan_id
204+
previous_rid = self._active_rid
205+
identity_changed = (
206+
(scan_id is not None and scan_id != previous_scan_id)
207+
or (rid is not None and rid != previous_rid)
208+
or (previous_scan_id is None and previous_rid is None)
209+
)
210+
211+
if self.task is None:
212+
self._start_task(scan_id, rid=rid)
213+
is_new_scan = identity_changed
214+
elif scan_id is not None and scan_id != self._active_scan_id:
215+
self._start_task(scan_id, rid=rid)
216+
is_new_scan = True
217+
elif rid is not None and rid != self._active_rid:
218+
self._start_task(scan_id or self._active_scan_id, rid=rid)
219+
is_new_scan = True
220+
221+
if self.task is None:
222+
return None
223+
224+
self.task.update(value, max_value, done)
225+
snapshot = ProgressSnapshot(
226+
value=value,
227+
max_value=max_value,
228+
done=done,
229+
status=status,
230+
scan_id=self._active_scan_id,
231+
scan_number=self.scan_number,
232+
rid=self._active_rid,
233+
is_new_scan=is_new_scan,
234+
)
235+
self.progress_updated.emit(snapshot)
236+
if done:
237+
self.clear_task()
238+
return snapshot
239+
240+
def cleanup(self) -> None:
241+
self.clear_task(emit_finished=False)
242+
if self._connected:
243+
self.bec_dispatcher.disconnect_slot(
244+
self.process_progress_message, MessageEndpoints.scan_progress()
245+
)
246+
self._connected = False

0 commit comments

Comments
 (0)