From 6981cce87f84b96b8972e7ba5a141fdc660253b7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 3 Nov 2024 16:05:46 -0500 Subject: [PATCH 1/7] feat: add runner from pymmcore-plus --- pyproject.toml | 2 +- src/useq/runner/__init__.py | 5 + src/useq/runner/_runner.py | 348 +++++++++++++++++++++++++++++++++++ src/useq/runner/protocols.py | 151 +++++++++++++++ src/useq/runner/pysgnals.py | 28 +++ tests/test_mda_runner.py | 84 +++++++++ 6 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 src/useq/runner/__init__.py create mode 100644 src/useq/runner/_runner.py create mode 100644 src/useq/runner/protocols.py create mode 100644 src/useq/runner/pysgnals.py create mode 100644 tests/test_mda_runner.py diff --git a/pyproject.toml b/pyproject.toml index 7abad9c..c568b66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ ignore = [ keep-runtime-typing = true [tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["D", "S101", "E501"] +"tests/*.py" = ["D", "S101", "E501", "SLF"] [tool.ruff.lint.flake8-tidy-imports] # Disallow all relative imports. diff --git a/src/useq/runner/__init__.py b/src/useq/runner/__init__.py new file mode 100644 index 0000000..4538f8e --- /dev/null +++ b/src/useq/runner/__init__.py @@ -0,0 +1,5 @@ +"""MDARunner class for running an Iterable[MDAEvent].""" + +from useq.runner._runner import MDARunner + +__all__ = ["MDARunner"] diff --git a/src/useq/runner/_runner.py b/src/useq/runner/_runner.py new file mode 100644 index 0000000..56a33ea --- /dev/null +++ b/src/useq/runner/_runner.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import logging +import time +import warnings +from contextlib import contextmanager +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from useq._mda_sequence import MDASequence +from useq.runner.protocols import PMDAEngine, PMDASignaler + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from useq import MDAEvent + + +MSG = ( + "This sequence is a placeholder for a generator of events with unknown " + "length & shape. Iterating over it has no effect." +) + + +@contextmanager +def _exceptions_logged(logger: logging.Logger) -> Iterator[None]: + """Context manager to log exceptions.""" + try: + yield + except Exception as e: + logger.error(e) + + +class GeneratorMDASequence(MDASequence): + axis_order: tuple[str, ...] = () + + @property + def sizes(self) -> dict[str, int]: # pragma: no cover + warnings.warn(MSG, stacklevel=2) + return {} + + def iter_axis(self, axis: str) -> Iterator: # pragma: no cover + warnings.warn(MSG, stacklevel=2) + yield from [] + + def __str__(self) -> str: + return "GeneratorMDASequence()" + + +class MDARunner: + """Object that executes a multi-dimensional experiment using an MDAEngine. + + This object is available at [`CMMCorePlus.mda`][pymmcore_plus.CMMCorePlus.mda]. + + This is the main object that runs a multi-dimensional experiment; it does so by + driving an acquisition engine that implements the + [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] protocol. It emits signals at specific + times during the experiment (see + [`PMDASignaler`][pymmcore_plus.mda.events.PMDASignaler] for details on the signals + that are available to connect to and when they are emitted). + """ + + def __init__( + self, signal_emitter: PMDASignaler, logger: logging.Logger | None = None + ) -> None: + self._engine: PMDAEngine | None = None + self._signals = signal_emitter + self._logger = logger or logging.getLogger(__name__) + + self._running = False + self._paused = False + self._paused_time: float = 0 + self._pause_interval: float = 0.1 # sec to wait between checking pause state + + self._canceled = False + self._sequence: MDASequence | None = None + # timer for the full sequence, reset only once at the beginning of the sequence + self._sequence_t0: float = 0.0 + # event clock, reset whenever `event.reset_event_timer` is True + self._t0: float = 0.0 + + def set_engine(self, engine: PMDAEngine) -> PMDAEngine | None: + """Set the [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] to use for the MDA run.""" # noqa: E501 + # MagicMock on py312 no longer satisfies isinstance ... so we explicitly + # allow it here just for the sake of testing. + if not isinstance(engine, (PMDAEngine, MagicMock)): + raise TypeError("Engine does not conform to the Engine protocol.") + + if self.is_running(): # pragma: no cover + raise RuntimeError( + "Cannot register a new engine when the current engine is running " + "an acquisition. Please cancel the current engine's acquisition " + "before registering" + ) + + old_engine, self._engine = self._engine, engine + return old_engine + + @property + def engine(self) -> PMDAEngine | None: + """The [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] that is currently being used.""" # noqa: E501 + return self._engine + + @property + def events(self) -> PMDASignaler: + """Signals that are emitted during the MDA run. + + See [`PMDASignaler`][pymmcore_plus.mda.PMDASignaler] for details on the + signals that are available to connect to. + """ + return self._signals + + def is_running(self) -> bool: + """Return True if an acquisition is currently underway. + + This will return True at any point between the emission of the + [`sequenceStarted`][pymmcore_plus.mda.PMDASignaler.sequenceStarted] and + [`sequenceFinished`][pymmcore_plus.mda.PMDASignaler.sequenceFinished] signals, + including when the acquisition is currently paused. + + Returns + ------- + bool + Whether an acquisition is underway. + """ + return self._running + + def is_paused(self) -> bool: + """Return True if the acquisition is currently paused. + + Use `toggle_pause` to change the paused state. + + Returns + ------- + bool + Whether the current acquisition is paused. + """ + return self._paused + + def cancel(self) -> None: + """Cancel the currently running acquisition. + + This is a no-op if no acquisition is currently running. + If an acquisition is running then this will cancel the acquisition and + a sequenceCanceled signal, followed by a sequenceFinished signal will + be emitted. + """ + self._canceled = True + self._paused_time = 0 + + def toggle_pause(self) -> None: + """Toggle the paused state of the current acquisition. + + To get whether the acquisition is currently paused use the + [`is_paused`][pymmcore_plus.mda.MDARunner.is_paused] method. This method is a + no-op if no acquisition is currently underway. + """ + if self.is_running(): + self._paused = not self._paused + self._signals.sequencePauseToggled.emit(self._paused) + + def run( + self, + events: Iterable[MDAEvent], + ) -> None: + """Run the multi-dimensional acquisition defined by `sequence`. + + Most users should not use this directly as it will block further + execution. Instead, use the + [`CMMCorePlus.run_mda`][pymmcore_plus.CMMCorePlus.run_mda] method which will + run on a thread. + + Parameters + ---------- + events : Iterable[MDAEvent] + An iterable of `useq.MDAEvents` objects to execute. + """ + error = None + sequence = events if isinstance(events, MDASequence) else GeneratorMDASequence() + # NOTE: it's important that `_prepare_to_run` and `_finish_run` are + # called inside the context manager, since the `mda_listeners_connected` + # context manager expects to see both of those signals. + try: + engine = self._prepare_to_run(sequence) + self._run(engine, events) + except Exception as e: + error = e + with _exceptions_logged(self._logger): + self._finish_run(sequence) + if error is not None: + raise error + + def seconds_elapsed(self) -> float: + """Return the number of seconds since the start of the acquisition.""" + return time.perf_counter() - self._sequence_t0 + + def event_seconds_elapsed(self) -> float: + """Return the number of seconds on the "event clock". + + This is the time since either the start of the acquisition or the last + event with `reset_event_timer` set to `True`. + """ + return time.perf_counter() - self._t0 + + def _run(self, engine: PMDAEngine, events: Iterable[MDAEvent]) -> None: + """Main execution of events, inside the try/except block of `run`.""" + teardown_event = getattr(engine, "teardown_event", lambda e: None) + event_iterator = getattr(engine, "event_iterator", iter) + _events: Iterator[MDAEvent] = event_iterator(events) + self._reset_event_timer() + self._sequence_t0 = self._t0 + + for event in _events: + if event.reset_event_timer: + self._reset_event_timer() + # If cancelled break out of the loop + if self._wait_until_event(event) or not self._running: + break + + self._signals.eventStarted.emit(event) + self._logger.info("%s", event) + engine.setup_event(event) + + try: + runner_time_ms = self.seconds_elapsed() * 1000 + # this is a bit of a hack to pass the time into the engine + # it is used for intra-event time calculations inside the engine. + # we pop it off after the event is executed. + event.metadata["runner_t0"] = self._sequence_t0 + output = engine.exec_event(event) or () # in case output is None + for payload in output: + img, event, meta = payload + event.metadata.pop("runner_t0", None) + # if the engine calculated its own time, don't overwrite it + if "runner_time_ms" not in meta: + meta["runner_time_ms"] = runner_time_ms + with _exceptions_logged(self._logger): + self._signals.frameReady.emit(img, event, meta) + finally: + teardown_event(event) + + def _prepare_to_run(self, sequence: MDASequence) -> PMDAEngine: + """Set up for the MDA run. + + Parameters + ---------- + sequence : MDASequence + The sequence of events to run. + """ + if not self._engine: # pragma: no cover + raise RuntimeError("No MDAEngine set.") + + self._running = True + self._paused = False + self._paused_time = 0.0 + self._sequence = sequence + + meta = self._engine.setup_sequence(sequence) + self._signals.sequenceStarted.emit(sequence, meta or {}) + self._logger.info("MDA Started: %s", sequence) + return self._engine + + def _reset_event_timer(self) -> None: + self._t0 = time.perf_counter() # reference time, in seconds + + def _check_canceled(self) -> bool: + """Return True if the cancel method has been called and emit relevant signals. + + If cancelled, this relies on the `self._sequence` being the current sequence + in order to emit a `sequenceCanceled` signal. + + Returns + ------- + bool + Whether the MDA has been canceled. + """ + if self._canceled: + self._logger.warning("MDA Canceled: %s", self._sequence) + self._signals.sequenceCanceled.emit(self._sequence) + self._canceled = False + return True + return False + + def _wait_until_event(self, event: MDAEvent) -> bool: + """Wait until the event's min start time, checking for pauses cancellations. + + Parameters + ---------- + event : MDAEvent + The event to wait for. + + Returns + ------- + bool + Whether the MDA was cancelled while waiting. + """ + if not self.is_running(): + return False # pragma: no cover + if self._check_canceled(): + return True + while self.is_paused() and not self._canceled: + self._paused_time += self._pause_interval # fixme: be more precise + time.sleep(self._pause_interval) + + if self._check_canceled(): + return True + + # FIXME: this is actually the only place where the runner assumes our event is + # an MDAevent. For everything else, the engine is technically the only thing + # that cares about the event time. + # So this whole method could potentially be moved to the engine. + if event.min_start_time: + go_at = event.min_start_time + self._paused_time + # We need to enter a loop here checking paused and canceled. + # otherwise you'll potentially wait a long time to cancel + remaining_wait_time = go_at - self.event_seconds_elapsed() + while remaining_wait_time > 0: + self._signals.awaitingEvent.emit(event, remaining_wait_time) + while self._paused and not self._canceled: + self._paused_time += self._pause_interval # fixme: be more precise + remaining_wait_time += self._pause_interval + time.sleep(self._pause_interval) + + if self._canceled: + break + time.sleep(min(remaining_wait_time, 0.5)) + remaining_wait_time = go_at - self.event_seconds_elapsed() + + # check canceled again in case it was canceled + # during the waiting loop + return self._check_canceled() + + def _finish_run(self, sequence: MDASequence) -> None: + """To be called at the end of an acquisition. + + Parameters + ---------- + sequence : MDASequence + The sequence that was finished. + """ + self._running = False + self._canceled = False + + if hasattr(self._engine, "teardown_sequence"): + self._engine.teardown_sequence(sequence) # type: ignore + + self._logger.info("MDA Finished: %s", sequence) + self._signals.sequenceFinished.emit(sequence) diff --git a/src/useq/runner/protocols.py b/src/useq/runner/protocols.py new file mode 100644 index 0000000..9686d68 --- /dev/null +++ b/src/useq/runner/protocols.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING, Protocol, TypeAlias, Union, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Iterable + from typing import Any, Callable + + from numpy.typing import NDArray + + from useq import MDAEvent, MDASequence + + PImagePayload = tuple[NDArray, MDAEvent, dict] + + +@runtime_checkable +class PMDAEngine(Protocol): + """Protocol that all MDA engines must implement.""" + + @abstractmethod + def setup_sequence(self, sequence: MDASequence) -> dict | None: + """Setup state of system (hardware, etc.) before an MDA is run. + + This method is called once at the beginning of a sequence. + """ + + @abstractmethod + def setup_event(self, event: MDAEvent) -> None: + """Prepare state of system (hardware, etc.) for `event`. + + This method is called before each event in the sequence. It is + responsible for preparing the state of the system for the event. + The engine should be in a state where it can call `exec_event` + without any additional preparation. (This means that the engine + should perform any waits or blocks required for system state + changes to complete.) + """ + + @abstractmethod + def exec_event(self, event: MDAEvent) -> Iterable[PImagePayload]: + """Execute `event`. + + This method is called after `setup_event` and is responsible for + executing the event. The default assumption is to acquire an image, + but more elaborate events will be possible. + + The protocol for the returned object is still under development. However, if + the returned object has an `image` attribute, then the + [`MDARunner`][pymmcore_plus.mda.MDARunner] will emit a + [`frameReady`][pymmcore_plus.mda.PMDASignaler.frameReady] signal + """ + # TODO: nail down a spec for the return object. + + # ------------- The following methods are optional ------------- + + # def event_iterator(self, events: Iterable[MDAEvent]) -> Iterator[MDAEvent]: + # """Wrapper on the event iterator. + + # **Optional.** + + # This can be used to wrap the event iterator to perform any event merging + # (e.g. if the engine supports HardwareSequencing) or event modification. + # The default implementation is just `iter(events)`. + + # Be careful when using this method. It is powerful and can result in + # unexpected event iteration if used incorrectly. + # """ + + # def teardown_event(self, event: MDAEvent) -> None: + # """Teardown state of system (hardware, etc.) after `event`. + + # **Optional.** + + # If the engine provides this function, it will be called after + # `exec_event` to perform any cleanup or teardown required after + # the event has been executed. + # """ + + # def teardown_sequence(self, sequence: MDASequence) -> None: + # """Perform any teardown required after the sequence has been executed. + + # **Optional.** + + # If the engine provides this function, it will be called after the + # last event in the sequence has been executed. + # """ + + +@runtime_checkable +class PSignalInstance(Protocol): + """The protocol that a signal instance must implement. + + In practice this will likely be either a `pyqtSignal/pyqtBoundSignal` or a + `psygnal.SignalInstance`. + """ + + def connect(self, slot: Callable) -> Any: + """Connect slot to this signal.""" + + def disconnect(self, slot: Callable | None = None) -> Any: + """Disconnect slot from this signal. + + If `None`, all slots should be disconnected. + """ + + def emit(self, *args: Any) -> Any: + """Emits the signal with the given arguments.""" + + +@runtime_checkable +class PSignalDescriptor(Protocol): + """Descriptor that returns a signal instance.""" + + def __get__(self, instance: Any | None, owner: Any) -> PSignalInstance: + """Returns the signal instance for this descriptor.""" + + +PSignal: TypeAlias = Union[PSignalDescriptor, PSignalInstance] + + +@runtime_checkable +class PMDASignaler(Protocol): + """Declares the protocol for all signals that will be emitted from [`pymmcore_plus.mda.MDARunner`][].""" # noqa: E501 + + sequenceStarted: PSignal + """Emits `(sequence: MDASequence, metadata: dict)` when an acquisition sequence is started. + + For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will + be of type [`SummaryMetaV1`][pymmcore_plus.metadata.schema.SummaryMetaV1]. + """ # noqa: E501 + sequencePauseToggled: PSignal + """Emits `(paused: bool)` when an acquisition sequence is paused or unpaused.""" + sequenceCanceled: PSignal + """Emits `(sequence: MDASequence)` when an acquisition sequence is canceled.""" + sequenceFinished: PSignal + """Emits `(sequence: MDASequence)` when an acquisition sequence is finished.""" + frameReady: PSignal + """Emits `(img: np.ndarray, event: MDAEvent, metadata: dict)` after an image is acquired during an acquisition sequence. + + For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will + be of type [`FrameMetaV1`][pymmcore_plus.metadata.schema.FrameMetaV1]. + """ # noqa: E501 + awaitingEvent: PSignal + """Emits `(event: MDAEvent, remaining_sec: float)` when the runner is waiting to start an event. + + Note: Not all events in a sequence will emit this signal. This will only be emitted + if the wait time is non-zero. + """ # noqa: E501 + eventStarted: PSignal + """Emits `(event: MDAEvent)` immediately before event setup and execution.""" diff --git a/src/useq/runner/pysgnals.py b/src/useq/runner/pysgnals.py new file mode 100644 index 0000000..46f4d1b --- /dev/null +++ b/src/useq/runner/pysgnals.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from useq._mda_event import MDAEvent +from useq._mda_sequence import MDASequence + +if TYPE_CHECKING: + from useq.runner.protocols import PSignal + +try: + from psygnal import Signal, SignalGroup +except ImportError as e: + raise ImportError("Please install psygnal to use this module.") from e + + +class MDASignaler(SignalGroup): + """Psygnal-backed signal-emitter for MDA signals emitted by the runner.""" + + sequenceStarted: PSignal = Signal(MDASequence, dict) + sequencePauseToggled: PSignal = Signal(bool) + sequenceCanceled: PSignal = Signal(MDASequence) + sequenceFinished: PSignal = Signal(MDASequence) + frameReady: PSignal = Signal(np.ndarray, MDAEvent, dict) + awaitingEvent: PSignal = Signal(MDAEvent, float) + eventStarted: PSignal = Signal(MDAEvent) diff --git a/tests/test_mda_runner.py b/tests/test_mda_runner.py new file mode 100644 index 0000000..578cba3 --- /dev/null +++ b/tests/test_mda_runner.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterable +from unittest.mock import Mock, patch + +import numpy as np +import pytest + +from useq import MDAEvent, MDASequence +from useq.runner import MDARunner +from useq.runner.pysgnals import MDASignaler + +if TYPE_CHECKING: + from useq.runner.protocols import PImagePayload + + +class GoodEngine: + def setup_sequence(self, sequence: MDASequence) -> None: ... + + def setup_event(self, event: MDAEvent) -> None: ... + + def exec_event(self, event: MDAEvent) -> Iterable[PImagePayload]: + yield (np.ndarray(0), event, {}) + + # def event_iterator(self, events: Iterable[MDAEvent]) -> Iterator[MDAEvent]: + # yield from events + # def teardown_event(self, event: MDAEvent) -> None: ... + # def teardown_sequence(self, sequence: MDASequence) -> None: ... + + +class BrokenEngine(GoodEngine): + def setup_event(self, event: MDAEvent) -> None: + raise ValueError("something broke") + + +MDA = MDASequence( + channels=["Cy5"], + time_plan={"interval": 0.2, "loops": 2}, + axis_order="tpcz", + stage_positions=[(222, 1, 1), (111, 0, 0)], +) + + +def test_mda_runner() -> None: + runner = MDARunner(MDASignaler()) + runner.set_engine(GoodEngine()) + + start_mock = Mock() + frame_mock = Mock() + finished_mock = Mock() + runner.events.sequenceStarted.connect(start_mock) + runner.events.frameReady.connect(frame_mock) + runner.events.sequenceFinished.connect(finished_mock) + runner.run(MDA) + + start_mock.assert_called_once_with(MDA, {}) + frame_mock.assert_called() + finished_mock.assert_called_once_with(MDA) + + +def test_mda_failures() -> None: + runner = MDARunner(MDASignaler()) + runner.set_engine(GoodEngine()) + + # error in user callback + def cb(img: Any, event: Any) -> None: + raise ValueError("uh oh") + + runner.events.frameReady.connect(cb) + runner.run(MDA) + + assert not runner.is_running() + assert not runner.is_paused() + assert not runner._canceled + runner.events.frameReady.disconnect(cb) + + # Hardware failure, e.g. a serial connection error + # we should fail gracefully + with patch.object(runner, "_engine", BrokenEngine()): + with pytest.raises(ValueError): + runner.run(MDA) + assert not runner.is_running() + assert not runner.is_paused() + assert not runner._canceled From ed6e44ae21063751560c8b65150d2d8c77d9aa6e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 3 Nov 2024 16:06:35 -0500 Subject: [PATCH 2/7] add dep --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c568b66..2a44014 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = ["pydantic >=2.6", "numpy", "typing-extensions"] # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] yaml = ["PyYAML"] -test = ["pytest>=6.0", "pytest-cov", "PyYAML"] +test = ["pytest>=6.0", "pytest-cov", "PyYAML", "psygnal"] dev = [ "ipython", "mypy", From 82e3280e320c40644142738889115563ed47243f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 21 Nov 2024 13:15:20 -0500 Subject: [PATCH 3/7] fix hint --- src/useq/runner/protocols.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/useq/runner/protocols.py b/src/useq/runner/protocols.py index 9686d68..2677419 100644 --- a/src/useq/runner/protocols.py +++ b/src/useq/runner/protocols.py @@ -1,7 +1,9 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Protocol, TypeAlias, Union, runtime_checkable +from typing import TYPE_CHECKING, Protocol, Union, runtime_checkable + +from typing_extensions import TypeAlias if TYPE_CHECKING: from collections.abc import Iterable From 7892c2fbba7c2e1cba64e92ef5027a66b09af254 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 21 Nov 2024 13:18:42 -0500 Subject: [PATCH 4/7] try fix 3.8 --- src/useq/runner/_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/useq/runner/_runner.py b/src/useq/runner/_runner.py index 56a33ea..38a3f20 100644 --- a/src/useq/runner/_runner.py +++ b/src/useq/runner/_runner.py @@ -4,7 +4,7 @@ import time import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Tuple from unittest.mock import MagicMock from useq._mda_sequence import MDASequence @@ -32,7 +32,7 @@ def _exceptions_logged(logger: logging.Logger) -> Iterator[None]: class GeneratorMDASequence(MDASequence): - axis_order: tuple[str, ...] = () + axis_order: Tuple[str, ...] = () @property def sizes(self) -> dict[str, int]: # pragma: no cover From 90415299369f603ff2d06e01833e8d21bd85fa1e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 21 Nov 2024 20:49:42 +0000 Subject: [PATCH 5/7] style(pre-commit.ci): auto fixes [...] --- src/useq/runner/_runner.py | 4 ++-- tests/test_mda_runner.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/useq/runner/_runner.py b/src/useq/runner/_runner.py index 38a3f20..56a33ea 100644 --- a/src/useq/runner/_runner.py +++ b/src/useq/runner/_runner.py @@ -4,7 +4,7 @@ import time import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING from unittest.mock import MagicMock from useq._mda_sequence import MDASequence @@ -32,7 +32,7 @@ def _exceptions_logged(logger: logging.Logger) -> Iterator[None]: class GeneratorMDASequence(MDASequence): - axis_order: Tuple[str, ...] = () + axis_order: tuple[str, ...] = () @property def sizes(self) -> dict[str, int]: # pragma: no cover diff --git a/tests/test_mda_runner.py b/tests/test_mda_runner.py index 578cba3..0e1155c 100644 --- a/tests/test_mda_runner.py +++ b/tests/test_mda_runner.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any from unittest.mock import Mock, patch import numpy as np @@ -11,6 +11,8 @@ from useq.runner.pysgnals import MDASignaler if TYPE_CHECKING: + from collections.abc import Iterable + from useq.runner.protocols import PImagePayload From 846ffc3ceb34707ffcaadfa39d04bdfb327e4cda Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 21 Nov 2024 16:18:29 -0500 Subject: [PATCH 6/7] more coverage --- src/useq/runner/__init__.py | 3 ++- src/useq/runner/_runner.py | 8 +++--- tests/test_mda_runner.py | 50 +++++++++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/useq/runner/__init__.py b/src/useq/runner/__init__.py index 4538f8e..9207eee 100644 --- a/src/useq/runner/__init__.py +++ b/src/useq/runner/__init__.py @@ -1,5 +1,6 @@ """MDARunner class for running an Iterable[MDAEvent].""" from useq.runner._runner import MDARunner +from useq.runner.protocols import PMDAEngine -__all__ = ["MDARunner"] +__all__ = ["MDARunner", "PMDAEngine"] diff --git a/src/useq/runner/_runner.py b/src/useq/runner/_runner.py index 38a3f20..06a767b 100644 --- a/src/useq/runner/_runner.py +++ b/src/useq/runner/_runner.py @@ -4,7 +4,7 @@ import time import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING from unittest.mock import MagicMock from useq._mda_sequence import MDASequence @@ -32,7 +32,7 @@ def _exceptions_logged(logger: logging.Logger) -> Iterator[None]: class GeneratorMDASequence(MDASequence): - axis_order: Tuple[str, ...] = () + axis_order: tuple[str, ...] = () @property def sizes(self) -> dict[str, int]: # pragma: no cover @@ -43,7 +43,7 @@ def iter_axis(self, axis: str) -> Iterator: # pragma: no cover warnings.warn(MSG, stacklevel=2) yield from [] - def __str__(self) -> str: + def __str__(self) -> str: # pragma: no cover return "GeneratorMDASequence()" @@ -83,7 +83,7 @@ def set_engine(self, engine: PMDAEngine) -> PMDAEngine | None: """Set the [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] to use for the MDA run.""" # noqa: E501 # MagicMock on py312 no longer satisfies isinstance ... so we explicitly # allow it here just for the sake of testing. - if not isinstance(engine, (PMDAEngine, MagicMock)): + if not isinstance(engine, (PMDAEngine, MagicMock)): # pragma: no cover raise TypeError("Engine does not conform to the Engine protocol.") if self.is_running(): # pragma: no cover diff --git a/tests/test_mda_runner.py b/tests/test_mda_runner.py index 578cba3..eb22831 100644 --- a/tests/test_mda_runner.py +++ b/tests/test_mda_runner.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable +import time +from queue import Queue +from threading import Thread +from typing import TYPE_CHECKING, Any from unittest.mock import Mock, patch import numpy as np @@ -11,6 +14,8 @@ from useq.runner.pysgnals import MDASignaler if TYPE_CHECKING: + from collections.abc import Iterable + from useq.runner.protocols import PImagePayload @@ -43,7 +48,9 @@ def setup_event(self, event: MDAEvent) -> None: def test_mda_runner() -> None: runner = MDARunner(MDASignaler()) - runner.set_engine(GoodEngine()) + engine = GoodEngine() + runner.set_engine(engine) + assert runner.engine is engine start_mock = Mock() frame_mock = Mock() @@ -58,6 +65,45 @@ def test_mda_runner() -> None: finished_mock.assert_called_once_with(MDA) +def test_mda_runner_pause_cancel() -> None: + runner = MDARunner(MDASignaler()) + runner.set_engine(GoodEngine()) + + pause_mock = Mock() + cancel_mock = Mock() + runner.events.sequencePauseToggled.connect(pause_mock) + runner.events.sequenceCanceled.connect(cancel_mock) + + q: Queue[MDAEvent] = Queue() + + thread = Thread(target=runner.run, args=(iter(q.get, None),)) + thread.start() + + # this is a little bit of a hack/bug for the Queue case, but if we don't process at + # least one event, the runner will never check for cancellation + q.put(MDAEvent()) + + t0 = time.time() + while not runner.is_running(): + if time.time() - t0 > 1: + raise TimeoutError("timeout") + time.sleep(0.01) + + assert not runner.is_paused() + runner.toggle_pause() + assert runner.is_paused() + pause_mock.assert_called_once_with(True) + + runner.toggle_pause() + assert not runner.is_paused() + pause_mock.assert_called_with(False) + + runner.cancel() + q.put(MDAEvent()) # same bug here + thread.join() + assert not runner.is_running() + + def test_mda_failures() -> None: runner = MDARunner(MDASignaler()) runner.set_engine(GoodEngine()) From 5e36e0706f6fd29a03e5767648edd9182f762970 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 21 Nov 2024 16:21:48 -0500 Subject: [PATCH 7/7] change namespace to experimental --- src/useq/experimental/__init__.py | 6 ++++++ src/useq/{runner => experimental}/_runner.py | 2 +- src/useq/{runner => experimental}/protocols.py | 0 src/useq/{runner => experimental}/pysgnals.py | 2 +- src/useq/runner/__init__.py | 6 ------ tests/test_mda_runner.py | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 src/useq/experimental/__init__.py rename src/useq/{runner => experimental}/_runner.py (99%) rename src/useq/{runner => experimental}/protocols.py (100%) rename src/useq/{runner => experimental}/pysgnals.py (94%) delete mode 100644 src/useq/runner/__init__.py diff --git a/src/useq/experimental/__init__.py b/src/useq/experimental/__init__.py new file mode 100644 index 0000000..720bb84 --- /dev/null +++ b/src/useq/experimental/__init__.py @@ -0,0 +1,6 @@ +"""MDARunner class for running an Iterable[MDAEvent].""" + +from useq.experimental._runner import MDARunner +from useq.experimental.protocols import PMDAEngine + +__all__ = ["MDARunner", "PMDAEngine"] diff --git a/src/useq/runner/_runner.py b/src/useq/experimental/_runner.py similarity index 99% rename from src/useq/runner/_runner.py rename to src/useq/experimental/_runner.py index 06a767b..6f52e13 100644 --- a/src/useq/runner/_runner.py +++ b/src/useq/experimental/_runner.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock from useq._mda_sequence import MDASequence -from useq.runner.protocols import PMDAEngine, PMDASignaler +from useq.experimental.protocols import PMDAEngine, PMDASignaler if TYPE_CHECKING: from collections.abc import Iterable, Iterator diff --git a/src/useq/runner/protocols.py b/src/useq/experimental/protocols.py similarity index 100% rename from src/useq/runner/protocols.py rename to src/useq/experimental/protocols.py diff --git a/src/useq/runner/pysgnals.py b/src/useq/experimental/pysgnals.py similarity index 94% rename from src/useq/runner/pysgnals.py rename to src/useq/experimental/pysgnals.py index 46f4d1b..ff31e06 100644 --- a/src/useq/runner/pysgnals.py +++ b/src/useq/experimental/pysgnals.py @@ -8,7 +8,7 @@ from useq._mda_sequence import MDASequence if TYPE_CHECKING: - from useq.runner.protocols import PSignal + from useq.experimental.protocols import PSignal try: from psygnal import Signal, SignalGroup diff --git a/src/useq/runner/__init__.py b/src/useq/runner/__init__.py deleted file mode 100644 index 9207eee..0000000 --- a/src/useq/runner/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""MDARunner class for running an Iterable[MDAEvent].""" - -from useq.runner._runner import MDARunner -from useq.runner.protocols import PMDAEngine - -__all__ = ["MDARunner", "PMDAEngine"] diff --git a/tests/test_mda_runner.py b/tests/test_mda_runner.py index eb22831..b69891d 100644 --- a/tests/test_mda_runner.py +++ b/tests/test_mda_runner.py @@ -10,13 +10,13 @@ import pytest from useq import MDAEvent, MDASequence -from useq.runner import MDARunner -from useq.runner.pysgnals import MDASignaler +from useq.experimental import MDARunner +from useq.experimental.pysgnals import MDASignaler if TYPE_CHECKING: from collections.abc import Iterable - from useq.runner.protocols import PImagePayload + from useq.experimental.protocols import PImagePayload class GoodEngine: