From e1e5cc61e820f5529f3b8d51dadab608b6946ed5 Mon Sep 17 00:00:00 2001 From: Charvin-Admin Date: Thu, 2 Apr 2026 13:05:34 +0200 Subject: [PATCH 1/6] Add live MDA status line to GUI widget (cherry picked from commit eece798c96a0e1b28eed6149e40edb28c4771192) --- src/pymmcore_gui/actions/widget_actions.py | 152 ++++++++++++++++++++- 1 file changed, 149 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index 634b5e6c..db177915 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -2,16 +2,17 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Annotated, TypeVar, cast import pymmcore_widgets as pmmw from pymmcore_plus import CMMCorePlus +from useq import AcquireImage, HardwareAutofocus, MDAEvent, MDASequence from pymmcore_gui._qt.QtAds import CDockWidget, DockWidgetArea, SideBarLocation -from pymmcore_gui._qt.QtCore import Qt +from pymmcore_gui._qt.QtCore import Qt, Signal from pymmcore_gui._qt.QtGui import QAction -from pymmcore_gui._qt.QtWidgets import QDialog, QVBoxLayout, QWidget +from pymmcore_gui._qt.QtWidgets import QDialog, QLabel, QVBoxLayout, QWidget from ._action_info import ActionKey, WidgetActionInfo, _ensure_isinstance @@ -105,11 +106,39 @@ def create_mda_widget(parent: QWidget) -> pmmw.MDAWidget: class MDAWidget(pmmw.MDAWidget): """MDAWidget subclass: defaults to in-memory output and hides tiff-sequence.""" + statusRequested = Signal(str) + def __init__( self, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None ) -> None: super().__init__(parent=parent, mmcore=mmcore) self._hide_tiff_sequence() + self._active_sequence: MDASequence | None = None + self._frame_total = 0 + self._frame_index = 0 + self._last_event: MDAEvent | None = None + self._is_paused = False + self._was_canceled = False + + self._status_label = QLabel("Idle", self) + self._status_label.setObjectName("mdaStatusLabel") + self._status_label.setWordWrap(True) + self._status_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + self.statusRequested.connect(self._status_label.setText) + + layout = cast("QVBoxLayout", self.layout()) + layout.addWidget(self._status_label) + + events = self._mmc.mda.events + events.sequenceStarted.connect(self._on_sequence_started) + events.eventStarted.connect(self._on_event_started) + events.frameReady.connect(self._on_frame_ready) + events.awaitingEvent.connect(self._on_awaiting_event) + events.sequencePauseToggled.connect(self._on_pause_toggled) + events.sequenceCanceled.connect(self._on_sequence_canceled) + events.sequenceFinished.connect(self._on_sequence_finished) def _hide_tiff_sequence(self) -> None: """Remove the 'tiff-sequence' option from the save widget's writer combo.""" @@ -125,6 +154,123 @@ def prepare_mda(self) -> bool | str | Path | None: output = "memory" return output + def _axis_value(self, event: MDAEvent | None, axis: str) -> int | None: + if event is None: + return None + for key, value in event.index.items(): + if str(key) == axis: + return int(value) + 1 + return None + + def _axis_total(self, axis: str) -> int | None: + if self._active_sequence is None: + return None + for key, value in self._active_sequence.sizes.items(): + if str(key) == axis and value: + return int(value) + return None + + def _channel_name(self, event: MDAEvent | None) -> str | None: + if event is None or event.channel is None: + return None + return event.channel.config or None + + def _event_produces_frame(self, event: MDAEvent) -> bool: + action = getattr(event, "action", None) + return action is None or isinstance(action, AcquireImage) + + def _count_expected_frames(self, sequence: MDASequence) -> int: + return sum( + 1 + for event in sequence.iter_events() + if self._event_produces_frame(event) + ) + + def _format_status( + self, + *, + step: str, + event: MDAEvent | None = None, + next_seconds: float | None = None, + ) -> str: + current_event = event or self._last_event + parts = [f"Frame {self._frame_index}/{self._frame_total}"] + + for axis, label in (("p", "Pos"), ("t", "T"), ("z", "Z")): + value = self._axis_value(current_event, axis) + total = self._axis_total(axis) + if value is not None and total: + parts.append(f"{label} {value}/{total}") + + if channel := self._channel_name(current_event): + parts.append(f"Channel {channel}") + + parts.append(f"Step: {step}") + + if next_seconds is not None: + parts.append(f"Next: {next_seconds:.1f} s") + + return " | ".join(parts) + + def _set_status( + self, + *, + step: str, + event: MDAEvent | None = None, + next_seconds: float | None = None, + ) -> None: + self.statusRequested.emit( + self._format_status(step=step, event=event, next_seconds=next_seconds) + ) + + def _on_sequence_started( + self, sequence: MDASequence, meta: Mapping[str, object] + ) -> None: + self._active_sequence = sequence + self._frame_total = self._count_expected_frames(sequence) + self._frame_index = 0 + self._last_event = None + self._is_paused = False + self._was_canceled = False + self._set_status(step="Preparing") + + def _on_event_started(self, event: MDAEvent) -> None: + self._last_event = event + action = getattr(event, "action", None) + if isinstance(action, HardwareAutofocus): + self._set_status(step="Autofocus", event=event) + else: + self._set_status(step="Acquiring", event=event) + + def _on_frame_ready( + self, image: object, event: MDAEvent, meta: Mapping[str, object] + ) -> None: + self._last_event = event + self._frame_index += 1 + self._set_status(step="Acquiring", event=event) + + def _on_awaiting_event(self, event: MDAEvent, remaining_sec: float) -> None: + self._last_event = event + step = "Paused" if self._is_paused else "Waiting next frame" + self._set_status(step=step, event=event, next_seconds=remaining_sec) + + def _on_pause_toggled(self, paused: bool) -> None: + self._is_paused = paused + step = "Paused" if paused else "Waiting next frame" + self._set_status(step=step) + + def _on_sequence_canceled(self, sequence: MDASequence) -> None: + self._was_canceled = True + self._set_status(step="Canceled") + + def _on_sequence_finished(self, sequence: MDASequence) -> None: + finish_reason = getattr(self._mmc.mda.status, "finish_reason", None) + if finish_reason is not None and str(finish_reason) == "errored": + step = "Error" + else: + step = "Canceled" if self._was_canceled else "Finished" + self._set_status(step=step) + return MDAWidget(parent=parent, mmcore=_get_core(parent)) From 0dcd22c56f313021bf2c7e28320e3bd51f4b85b4 Mon Sep 17 00:00:00 2001 From: Charvin-Admin Date: Tue, 7 Apr 2026 13:26:56 +0200 Subject: [PATCH 2/6] Add tests for MDA status line --- tests/test_actions.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 34b9eee6..c1b797f5 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,4 +1,5 @@ import pytest +import useq from pymmcore_gui import MicroManagerGUI from pymmcore_gui._qt.QtWidgets import QMenu, QWidget @@ -38,3 +39,49 @@ def test_actions_in_menus() -> None: ) assert isinstance(window_menu, QMenu) assert any(a.text() == text for a in window_menu.actions()) + + +def test_mda_widget_status_line(qtbot) -> None: + win = MicroManagerGUI() + qtbot.addWidget(win) + mda = win.get_widget(WidgetAction.MDA_WIDGET) + + sequence = useq.MDASequence( + stage_positions=[ + useq.Position(x=0.0, y=0.0, name="P1"), + useq.Position(x=1.0, y=1.0, name="P2"), + ], + channels=[useq.Channel(config="DAPI")], + time_plan=useq.TIntervalLoops(interval=0.1, loops=2), + ) + event = next(sequence.iter_events()) + + assert mda._status_label.text() == "Idle" + mda._on_sequence_started(sequence, {}) + assert mda._frame_total == 4 + assert mda._status_label.text() == "Frame 0/4 | Step: Preparing" + + mda._on_event_started(event) + assert "Pos 1/2" in mda._status_label.text() + assert "T 1/2" in mda._status_label.text() + assert "Channel DAPI" in mda._status_label.text() + assert mda._status_label.text().endswith("Step: Acquiring") + + mda._on_frame_ready(object(), event, {}) + assert mda._status_label.text().startswith("Frame 1/4") + + mda._on_awaiting_event(event, 1.25) + assert "Step: Waiting next frame" in mda._status_label.text() + assert "Next: 1.2 s" in mda._status_label.text() + + mda._on_pause_toggled(True) + assert "Step: Paused" in mda._status_label.text() + + af_event = useq.MDAEvent(index={"p": 0}, action=useq.HardwareAutofocus()) + mda._on_event_started(af_event) + assert "Step: Autofocus" in mda._status_label.text() + + mda._on_sequence_canceled(sequence) + assert "Step: Canceled" in mda._status_label.text() + mda._on_sequence_finished(sequence) + assert "Step: Canceled" in mda._status_label.text() From 38a9d67ff13ac9522b49bffc6641e0d1387f9545 Mon Sep 17 00:00:00 2001 From: Charvin-Admin Date: Tue, 7 Apr 2026 15:14:43 +0200 Subject: [PATCH 3/6] test: wait for MDA status signal delivery --- tests/test_actions.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index c1b797f5..312515ee 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -46,6 +46,10 @@ def test_mda_widget_status_line(qtbot) -> None: qtbot.addWidget(win) mda = win.get_widget(WidgetAction.MDA_WIDGET) + def wait_status(predicate) -> str: + qtbot.waitUntil(lambda: predicate(mda._status_label.text())) + return mda._status_label.text() + sequence = useq.MDASequence( stage_positions=[ useq.Position(x=0.0, y=0.0, name="P1"), @@ -59,29 +63,29 @@ def test_mda_widget_status_line(qtbot) -> None: assert mda._status_label.text() == "Idle" mda._on_sequence_started(sequence, {}) assert mda._frame_total == 4 - assert mda._status_label.text() == "Frame 0/4 | Step: Preparing" + assert wait_status(lambda text: text == "Frame 0/4 | Step: Preparing") mda._on_event_started(event) - assert "Pos 1/2" in mda._status_label.text() - assert "T 1/2" in mda._status_label.text() - assert "Channel DAPI" in mda._status_label.text() - assert mda._status_label.text().endswith("Step: Acquiring") + status = wait_status(lambda text: text.endswith("Step: Acquiring")) + assert "Pos 1/2" in status + assert "T 1/2" in status + assert "Channel DAPI" in status mda._on_frame_ready(object(), event, {}) - assert mda._status_label.text().startswith("Frame 1/4") + assert wait_status(lambda text: text.startswith("Frame 1/4")) mda._on_awaiting_event(event, 1.25) - assert "Step: Waiting next frame" in mda._status_label.text() - assert "Next: 1.2 s" in mda._status_label.text() + status = wait_status(lambda text: "Step: Waiting next frame" in text) + assert "Next: 1.2 s" in status mda._on_pause_toggled(True) - assert "Step: Paused" in mda._status_label.text() + assert "Step: Paused" in wait_status(lambda text: "Step: Paused" in text) af_event = useq.MDAEvent(index={"p": 0}, action=useq.HardwareAutofocus()) mda._on_event_started(af_event) - assert "Step: Autofocus" in mda._status_label.text() + assert "Step: Autofocus" in wait_status(lambda text: "Step: Autofocus" in text) mda._on_sequence_canceled(sequence) - assert "Step: Canceled" in mda._status_label.text() + assert "Step: Canceled" in wait_status(lambda text: "Step: Canceled" in text) mda._on_sequence_finished(sequence) - assert "Step: Canceled" in mda._status_label.text() + assert "Step: Canceled" in wait_status(lambda text: "Step: Canceled" in text) From 0edd475c23641fa7af7419a68339add0e716a885 Mon Sep 17 00:00:00 2001 From: Charvin-Admin Date: Tue, 7 Apr 2026 15:32:16 +0200 Subject: [PATCH 4/6] test: assert emitted MDA status signals --- tests/test_actions.py | 63 +++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 312515ee..2321af9d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,3 +1,9 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +from typing import Any, TypeAlias, cast + import pytest import useq @@ -5,6 +11,8 @@ from pymmcore_gui._qt.QtWidgets import QMenu, QWidget from pymmcore_gui.actions import ActionInfo, CoreAction, WidgetAction, WidgetActionInfo +StatusTrigger: TypeAlias = Callable[[], None] + def test_action_registry() -> None: info = ActionInfo.for_key(CoreAction.SNAP) @@ -41,51 +49,54 @@ def test_actions_in_menus() -> None: assert any(a.text() == text for a in window_menu.actions()) -def test_mda_widget_status_line(qtbot) -> None: +def test_mda_widget_status_line(qtbot: Any) -> None: win = MicroManagerGUI() qtbot.addWidget(win) - mda = win.get_widget(WidgetAction.MDA_WIDGET) + mda = cast("Any", win.get_widget(WidgetAction.MDA_WIDGET)) - def wait_status(predicate) -> str: - qtbot.waitUntil(lambda: predicate(mda._status_label.text())) - return mda._status_label.text() + def emit_status(trigger: StatusTrigger) -> str: + with qtbot.waitSignal(mda.statusRequested) as blocker: + trigger() + args = cast("tuple[object, ...] | None", blocker.args) + assert args is not None + return str(args[0]) sequence = useq.MDASequence( - stage_positions=[ + stage_positions=( useq.Position(x=0.0, y=0.0, name="P1"), useq.Position(x=1.0, y=1.0, name="P2"), - ], - channels=[useq.Channel(config="DAPI")], - time_plan=useq.TIntervalLoops(interval=0.1, loops=2), + ), + channels=(useq.Channel(config="DAPI", exposure=1.0),), + time_plan=useq.TIntervalLoops(interval=timedelta(seconds=0.1), loops=2), ) event = next(sequence.iter_events()) assert mda._status_label.text() == "Idle" - mda._on_sequence_started(sequence, {}) + status = emit_status(lambda: mda._on_sequence_started(sequence, {})) assert mda._frame_total == 4 - assert wait_status(lambda text: text == "Frame 0/4 | Step: Preparing") + assert status == "Frame 0/4 | Step: Preparing" - mda._on_event_started(event) - status = wait_status(lambda text: text.endswith("Step: Acquiring")) + status = emit_status(lambda: mda._on_event_started(event)) + assert status.endswith("Step: Acquiring") assert "Pos 1/2" in status assert "T 1/2" in status assert "Channel DAPI" in status - mda._on_frame_ready(object(), event, {}) - assert wait_status(lambda text: text.startswith("Frame 1/4")) + status = emit_status(lambda: mda._on_frame_ready(object(), event, {})) + assert status.startswith("Frame 1/4") - mda._on_awaiting_event(event, 1.25) - status = wait_status(lambda text: "Step: Waiting next frame" in text) + status = emit_status(lambda: mda._on_awaiting_event(event, 1.25)) + assert "Step: Waiting next frame" in status assert "Next: 1.2 s" in status - mda._on_pause_toggled(True) - assert "Step: Paused" in wait_status(lambda text: "Step: Paused" in text) + status = emit_status(lambda: mda._on_pause_toggled(True)) + assert "Step: Paused" in status - af_event = useq.MDAEvent(index={"p": 0}, action=useq.HardwareAutofocus()) - mda._on_event_started(af_event) - assert "Step: Autofocus" in wait_status(lambda text: "Step: Autofocus" in text) + af_event = useq.MDAEvent(action=useq.HardwareAutofocus()) + status = emit_status(lambda: mda._on_event_started(af_event)) + assert "Step: Autofocus" in status - mda._on_sequence_canceled(sequence) - assert "Step: Canceled" in wait_status(lambda text: "Step: Canceled" in text) - mda._on_sequence_finished(sequence) - assert "Step: Canceled" in wait_status(lambda text: "Step: Canceled" in text) + status = emit_status(lambda: mda._on_sequence_canceled(sequence)) + assert "Step: Canceled" in status + status = emit_status(lambda: mda._on_sequence_finished(sequence)) + assert "Step: Canceled" in status From 6b102af2b986453f93ca744e7c183a1e2c9845f2 Mon Sep 17 00:00:00 2001 From: Charvin-Admin Date: Tue, 7 Apr 2026 15:45:47 +0200 Subject: [PATCH 5/6] fix: use QObject status emitter for MDA updates --- src/pymmcore_gui/actions/widget_actions.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index db177915..aa19154c 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -10,7 +10,7 @@ from useq import AcquireImage, HardwareAutofocus, MDAEvent, MDASequence from pymmcore_gui._qt.QtAds import CDockWidget, DockWidgetArea, SideBarLocation -from pymmcore_gui._qt.QtCore import Qt, Signal +from pymmcore_gui._qt.QtCore import QObject, Qt, Signal from pymmcore_gui._qt.QtGui import QAction from pymmcore_gui._qt.QtWidgets import QDialog, QLabel, QVBoxLayout, QWidget @@ -20,7 +20,6 @@ from pathlib import Path from pymmcore_gui._main_window import MicroManagerGUI - from pymmcore_gui._qt.QtCore import QObject from pymmcore_gui.widgets._exception_log import ExceptionLog from pymmcore_gui.widgets._mm_console import MMConsole from pymmcore_gui.widgets._stage_control import StagesControlWidget @@ -30,6 +29,10 @@ CT = TypeVar("CT", bound=Callable[[QWidget], QWidget]) +class _MDAStatusEmitter(QObject): + statusRequested = Signal(str) + + class WidgetAction(ActionKey): """Widget Actions toggle/create singleton widgets.""" @@ -106,13 +109,12 @@ def create_mda_widget(parent: QWidget) -> pmmw.MDAWidget: class MDAWidget(pmmw.MDAWidget): """MDAWidget subclass: defaults to in-memory output and hides tiff-sequence.""" - statusRequested = Signal(str) - def __init__( self, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None ) -> None: super().__init__(parent=parent, mmcore=mmcore) self._hide_tiff_sequence() + self._status_emitter = _MDAStatusEmitter(self) self._active_sequence: MDASequence | None = None self._frame_total = 0 self._frame_index = 0 @@ -140,6 +142,10 @@ def __init__( events.sequenceCanceled.connect(self._on_sequence_canceled) events.sequenceFinished.connect(self._on_sequence_finished) + @property + def statusRequested(self): + return self._status_emitter.statusRequested + def _hide_tiff_sequence(self) -> None: """Remove the 'tiff-sequence' option from the save widget's writer combo.""" combo = self.save_info._writer_combo From 02dedc6705e3b6822a3103855c94a5e661c380f9 Mon Sep 17 00:00:00 2001 From: Charvin-Admin Date: Tue, 7 Apr 2026 15:51:26 +0200 Subject: [PATCH 6/6] fix: annotate MDA status signal property --- src/pymmcore_gui/actions/widget_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index aa19154c..53582b98 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Annotated, TypeVar, cast +from typing import TYPE_CHECKING, Annotated, Any, TypeVar, cast import pymmcore_widgets as pmmw from pymmcore_plus import CMMCorePlus @@ -143,7 +143,7 @@ def __init__( events.sequenceFinished.connect(self._on_sequence_finished) @property - def statusRequested(self): + def statusRequested(self) -> Any: return self._status_emitter.statusRequested def _hide_tiff_sequence(self) -> None: