From b092536006f726f228910e7fe6d73068b7a86060 Mon Sep 17 00:00:00 2001 From: hinderling Date: Wed, 5 Aug 2026 23:47:02 +0200 Subject: [PATCH 1/4] fix(moench): send SLM patterns as arrays so the Mosaic3 applies them A scalar-bool SLMImage reaches the base engine as setSLMPixelsTo, which the Mosaic3 ignores without raising, so the previously latched pattern stays on the mirrors. Expand the scalar to a uint8 array in MoenchMDAEngine._set_event_slm_image so it routes through setSLMImage. This makes an intended all-off actually clear the DMD, and re-displays the pattern on every imaging capture instead of relying on the latched state surviving between frames. --- faro/microscope/pertzlab/moench.py | 25 +++++- tests/hardware/pertzlab/test_pertzlab_unit.py | 81 +++++++++++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/faro/microscope/pertzlab/moench.py b/faro/microscope/pertzlab/moench.py index 92478d7..b3ac41c 100644 --- a/faro/microscope/pertzlab/moench.py +++ b/faro/microscope/pertzlab/moench.py @@ -1,6 +1,8 @@ import pymmcore_plus import weakref +import numpy as np + from faro.microscope.pymmcore import PyMMCoreMicroscope from faro.core.data_structures import ImgType from faro.core.dmd import DMD @@ -780,7 +782,7 @@ def _maybe_inject_dmd_wake_slm(self, event: MDAEvent) -> MDAEvent: ) def _set_event_slm_image(self, event: MDAEvent) -> None: - """Upload the SLM pattern, then force a long *hold* exposure on the DMD. + """Upload the SLM pattern as an array, then force a long *hold* exposure. The base method uploads the image and, if the ``SLMImage`` carries an exposure, writes it via ``setSLMExposure``. On the Mosaic3 that value is @@ -791,6 +793,26 @@ def _set_event_slm_image(self, event: MDAEvent) -> None: mode). The stim *dose* is unaffected -- it is gated by the camera-triggered LED, not the DMD. See ``nikonti-re/mosaic3/FINDINGS.md``. """ + core = self.mmcore + # A scalar-bool SLMImage (all-on/all-off) reaches the base engine as + # setSLMPixelsTo, which the Mosaic3 ignores without raising, leaving + # whatever pattern was last latched on the mirrors. Expanding the + # scalar to a uint8 array routes it through setSLMImage instead, the + # only path this DMD reliably applies. + if event.slm_image is not None: + data = np.asarray(event.slm_image.data) + if data.ndim == 0: + slm_dev = event.slm_image.device or core.getSLMDevice() + full = np.full( + (core.getSLMHeight(slm_dev), core.getSLMWidth(slm_dev)), + 255 if bool(data.item()) else 0, + dtype=np.uint8, + ) + event = event.model_copy( + update={ + "slm_image": event.slm_image.model_copy(update={"data": full}) + } + ) super()._set_event_slm_image(event) if event.slm_image is None: return @@ -800,7 +822,6 @@ def _set_event_slm_image(self, event: MDAEvent) -> None: ) if not hold_ms: return - core = self.mmcore slm_device = event.slm_image.device or core.getSLMDevice() if not slm_device: return diff --git a/tests/hardware/pertzlab/test_pertzlab_unit.py b/tests/hardware/pertzlab/test_pertzlab_unit.py index becb3e6..453e3d6 100644 --- a/tests/hardware/pertzlab/test_pertzlab_unit.py +++ b/tests/hardware/pertzlab/test_pertzlab_unit.py @@ -1,11 +1,8 @@ """Pure-Python unit tests for Pertzlab-specific faro code. Lives under ``tests/hardware/pertzlab/`` because its subjects are -Pertzlab-scope-only: per-microscope power-property mappings (declared -manually; an unmapped ``PowerChannel`` fails loud rather than silently -dropping the requested power) and -:class:`faro.microscope.pertzlab.moench.MoenchMDAEngine`'s -``SKIP_WAIT_DEVICES`` filter. +Pertzlab-scope-only: power-property mappings, ``SKIP_WAIT_DEVICES``, +filter-turret verification, and the Moench engine's SLM uploads. These do **not** require a real scope and are **not** marked ``@pytest.mark.hardware``; they run in every test session. @@ -15,9 +12,15 @@ from types import SimpleNamespace +import numpy as np import pytest +from useq import MDAEvent +from faro.core._useq_compat import SLMImage from faro.core.data_structures import Channel, PowerChannel +from faro.microscope.pertzlab.moench import MoenchMDAEngine + +from tests.fake_mmc import build_core # =================================================================== @@ -341,3 +344,71 @@ def test_device_not_loaded_is_noop(self): assert mmc.getState_calls == 0 assert mmc.setStateLabel_calls == [] + + +# =================================================================== +# SLM uploads +# =================================================================== + + +class _SLMScene: + """Minimal scene declaring a camera and an SLM; never renders.""" + + image_height = image_width = 64 + channels = ("phase-contrast",) + slm_name = "SLM" + slm_shape = (64, 64) + + def render(self, event): + return np.zeros((self.image_height, self.image_width), dtype=np.uint16) + + +@pytest.fixture() +def slm_core(): + return build_core(_SLMScene()) + + +def _record_slm_calls(core): + """Replace the core's two SLM upload paths with recorders.""" + calls = [] + core.setSLMImage = lambda label, image: calls.append(("setSLMImage", image)) + core.setSLMPixelsTo = lambda *args: calls.append(("setSLMPixelsTo", args)) + return calls + + +class TestScalarSLMImageExpansion: + """Every SLM command reaches the core as an array, never as a scalar. + + A scalar all-on/all-off goes out via ``setSLMPixelsTo``, which some DMDs + ignore without raising, leaving the last pattern on the mirrors. + """ + + @pytest.mark.parametrize( + ("data", "expected_value"), [(True, 255), (False, 0)], ids=["all-on", "all-off"] + ) + def test_scalar_routed_through_set_slm_image(self, slm_core, data, expected_value): + engine = MoenchMDAEngine(slm_core) + calls = _record_slm_calls(slm_core) + + engine._set_event_slm_image( + MDAEvent(slm_image=SLMImage(data=data, device="SLM")) + ) + + assert [name for name, _ in calls] == ["setSLMImage"] + image = calls[0][1] + assert image.shape == _SLMScene.slm_shape + assert image.dtype == np.uint8 + assert (image == expected_value).all() + + def test_array_is_passed_through_unchanged(self, slm_core): + engine = MoenchMDAEngine(slm_core) + calls = _record_slm_calls(slm_core) + mask = np.zeros(_SLMScene.slm_shape, dtype=np.uint8) + mask[10:20, 10:20] = 255 + + engine._set_event_slm_image( + MDAEvent(slm_image=SLMImage(data=mask, device="SLM")) + ) + + assert [name for name, _ in calls] == ["setSLMImage"] + assert np.array_equal(calls[0][1], mask) From 6d2213b5aca30e0bb1ac633c11e2178d119e5841 Mon Sep 17 00:00:00 2001 From: hinderling Date: Wed, 5 Aug 2026 23:47:24 +0200 Subject: [PATCH 2/4] fix(controller): wait for a stim's source frame before building its mask The feed loop runs a few events ahead of the camera, so it reached a stim event and blocked on get_stim_mask for a frame that had not been shot yet. At minute-scale intervals the mask wait expired before the frame existed and the stim fell through to an all-off mask. Track which (t, p) imaging frames have reached the pipeline and wait for a stim's source frame ((t-1, p) in "previous" mode, (t, p) in "current") before asking for its mask. A frame that never arrives cannot have a mask, so a timed-out wait goes straight to the all-off fallback rather than spending the mask timeout again on a lookup that must fail. --- faro/core/controller.py | 90 ++++++++++++++++++++++++++++++++++++----- tests/test_stim_gate.py | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 tests/test_stim_gate.py diff --git a/faro/core/controller.py b/faro/core/controller.py index e3df62c..1413535 100644 --- a/faro/core/controller.py +++ b/faro/core/controller.py @@ -734,6 +734,12 @@ def __init__(self, mic, pipeline, *, writer: Writer | None = None): self._n_channels: int = 1 self._frame_buffers: dict[tuple, list] = {} + # (t, p) of imaging frames whose channels have all arrived and been + # submitted to the pipeline. Written from the frameReady callback, + # read by the feed loop's stim gate. + self._acquired_frames: set[tuple[int, int]] = set() + self._acquired_lock = threading.Lock() + # Continuation state self._t_offset: int = 0 self._time_offset: float = 0.0 @@ -1200,15 +1206,22 @@ def _run_mda_with_events(self, events, *, stim_mode, handle: RunHandle): feed loop checks ``handle.cancel_event`` at each iteration so ``handle.cancel()`` returns control without a Ctrl-C. """ - # Live mode (continuous sequence acquisition) and MDA both drive the - # camera. If live is still running when the MDA's first snapImage - # fires, the snap buffer is consumed by the live-poll listener (in - # napari-micromanager: _core_link._image_snapped) before the engine - # calls getImage, and the engine raises "Camera image buffer read - # failed". Stop it unconditionally before MDA starts. + # Live mode (continuous sequence acquisition) and the MDA both drive + # the camera. A live stream still running when the MDA's first + # snapImage fires consumes the snap buffer (napari-micromanager's + # _core_link._image_snapped) before the engine calls getImage, and the + # engine raises "Camera image buffer read failed". + # + # This is not gated on isSequenceRunning(): napari-micromanager can + # hold a live timer while the stream is already stopped, and that timer + # restarts live acquisition on the configSet/exposureChanged signals + # the engine emits every frame. stopSequenceAcquisition() emits + # sequenceAcquisitionStopped, whose listener clears the timer, so it + # has to run even when no stream is currently active. mmc = getattr(self._mic, "mmc", None) - if mmc is not None and mmc.isSequenceRunning(): - mmc.stopSequenceAcquisition() + if mmc is not None: + with contextlib.suppress(Exception): + mmc.stopSequenceAcquisition() self._mic.connect_frame(self._on_frame_ready) @@ -1222,6 +1235,8 @@ def _run_mda_with_events(self, events, *, stim_mode, handle: RunHandle): # (the run "sticks" after a few events). A fresh queue per run # avoids that entirely. self._queue = Queue() + with self._acquired_lock: + self._acquired_frames.clear() # Set up event queue for extend_experiment support. # _pending_sentinels tracks how many extra batches (from @@ -1387,7 +1402,30 @@ def _run_mda_with_events(self, events, *, stim_mode, handle: RunHandle): for ev in planned: if ev.metadata.get("img_type") == ImgType.IMG_STIM: if slm is None and self._mic.dmd: - slm = self._build_stim_slm(rtm_event, stim_mode=stim_mode) + # The mask comes from the frame the pipeline is + # asked about: (t-1, p) in "previous" mode, (t, p) + # in "current" mode. The feed loop runs a few + # events ahead of the camera, so wait for that + # frame to arrive before blocking on its mask. + # Otherwise get_stim_mask waits out its whole + # timeout for a frame that has not been shot yet + # and falls through to an all-off mask. + # + # A frame that never arrives cannot have a mask, so + # a timed-out wait skips straight to the all-off + # fallback instead of spending the mask timeout + # over again on a lookup that must fail. + t_src = rtm_event.index.get("t", 0) + if stim_mode == "previous": + t_src -= 1 + acquired = self._wait_for_frame_acquired( + t_src, rtm_event.index.get("p", 0), handle + ) + if handle.cancel_event.is_set(): + break + slm = self._build_stim_slm( + rtm_event, stim_mode=stim_mode, has_mask=acquired + ) if slm is not None: ev = ev.model_copy(update={"slm_image": slm}) self._put_event(ev) @@ -1531,6 +1569,9 @@ def _on_frame_ready(self, img: np.ndarray, event: MDAEvent) -> None: if len(buf) >= n_expected: frame = np.stack(buf, axis=0) del self._frame_buffers[tp] + # Releases the feed loop's stim gate for any stim built off (t, p). + with self._acquired_lock: + self._acquired_frames.add(tp) self._analyzer.run(frame, event) def _abort_mda_from_callback(self, message: str) -> None: @@ -1551,8 +1592,29 @@ def _abort_mda_from_callback(self, message: str) -> None: # Stim helpers # ------------------------------------------------------------------ + def _wait_for_frame_acquired(self, t: int, p: int, handle: RunHandle) -> bool: + """Block until imaging frame ``(t, p)`` reaches the pipeline. + + Returns ``True`` if the frame arrived, ``False`` if the run was + cancelled, a fatal error aborted the MDA, or the wait timed out. The + frame is always queued in an earlier feed-loop iteration than the stim + that depends on it, so it normally arrives as soon as the engine works + through the queue. The timeout only bounds the pathological case where + it never arrives at all, so a stalled engine surfaces as the usual + stim-mask timeout instead of wedging the feed loop. + """ + deadline = time.monotonic() + self._analyzer._stim_mask_timeout + while not handle.cancel_event.is_set() and self._fatal_error is None: + with self._acquired_lock: + if (t, p) in self._acquired_frames: + return True + remaining = deadline - time.monotonic() + if remaining <= 0 or handle.cancel_event.wait(min(0.05, remaining)): + break + return False + def _build_stim_slm( - self, rtm_event, *, stim_mode: str = "current" + self, rtm_event, *, stim_mode: str = "current", has_mask: bool = True ) -> SLMImage | None: """Build SLMImage for stimulation via Analyzer's stim-mask API. @@ -1563,6 +1625,10 @@ def _build_stim_slm( asks for frame ``t-1``'s mask (stim fires before imaging, using the mask from the previous timepoint for the same FOV). + has_mask: ``False`` when the source frame is known not to have + been acquired, so no mask can exist for it. Skips the lookup + and returns the all-off fallback instead of waiting out the + stim-mask timeout on a query that cannot succeed. """ fov_index = rtm_event.index.get("p", 0) stim_ch = rtm_event.stim_channels[0] @@ -1581,7 +1647,7 @@ def _build_stim_slm( "timestep": t, } - stim_mask = self._analyzer.get_stim_mask(fov_index, meta) + stim_mask = self._analyzer.get_stim_mask(fov_index, meta) if has_mask else None if stim_mask is None: print("Warning: Stimulation mask unavailable, sending False to SLM.") stim_mask = False @@ -1641,6 +1707,8 @@ def _on_frame_ready(self, img: np.ndarray, event: MDAEvent) -> None: if len(buf) >= n_expected: del self._frame_buffers[tp] + with self._acquired_lock: + self._acquired_frames.add(tp) fname = meta["fname"] t_idx = event.index.get("t", 0) p_idx = event.index.get("p", 0) diff --git a/tests/test_stim_gate.py b/tests/test_stim_gate.py new file mode 100644 index 0000000..7542cbf --- /dev/null +++ b/tests/test_stim_gate.py @@ -0,0 +1,77 @@ +"""The feed loop waits for a stim's source frame before building its mask. + +The feed loop runs a few events ahead of the camera. Building a stim's SLM +blocks on ``get_stim_mask``, so without a gate it asks the pipeline for the +mask of a frame that has not been shot yet and burns the whole mask timeout +before falling back to an all-off mask. + +The source frame is ``(t-1, p)`` in ``previous`` mode and ``(t, p)`` in +``current`` mode, where the imaging events are queued from the same feed-loop +iteration as the stim that follows them. +""" + +from __future__ import annotations + +import time + +import pytest + +from faro.core.controller import Controller +from faro.tracking.trackpy import TrackerTrackpy + +from tests.fake_microscope import FakeMicroscope +from tests.fixtures import ( + CircleScene, + assert_no_background_errors, + make_events, + make_pipeline, +) + +N_TIMEPOINTS = 6 +STIM_FRAMES = (2, 3, 4, 5) +CAMERA_DELAY_S = 0.20 # slow enough that acquisition lags the feed loop + + +class SlowCircleScene(CircleScene): + """CircleScene whose camera takes long enough to fall behind the feed loop.""" + + def render(self, event): + time.sleep(CAMERA_DELAY_S) + return super().render(event) + + +@pytest.mark.parametrize( + ("stim_mode", "source_offset"), [("previous", -1), ("current", 0)] +) +def test_stim_slm_built_only_after_source_frame_acquired( + tmp_dir, stim_mode, source_offset +): + pipeline = make_pipeline( + tmp_dir, tracker=TrackerTrackpy(search_range=50, memory=3), with_stim=True + ) + mic = FakeMicroscope(SlowCircleScene(with_slm=True)) + ctrl = Controller(mic, pipeline) + + # Record whether the source frame was already acquired at each stim-SLM + # build, which is exactly what the gate has to guarantee. + build_stim_slm = ctrl._build_stim_slm + builds: list[tuple[int, bool]] = [] + + def record_build(rtm_event, **kwargs): + t = rtm_event.index.get("t", 0) + p = rtm_event.index.get("p", 0) + with ctrl._acquired_lock: + builds.append((t, (t + source_offset, p) in ctrl._acquired_frames)) + return build_stim_slm(rtm_event, **kwargs) + + ctrl._build_stim_slm = record_build + + events = make_events(N_TIMEPOINTS, stim_frames=STIM_FRAMES) + ctrl.run_experiment(events, stim_mode=stim_mode, validate=False).wait() + ctrl._analyzer.wait_idle(timeout=120) + ctrl._analyzer.shutdown(wait=True) + + assert_no_background_errors(ctrl) + assert [t for t, _ in builds] == list(STIM_FRAMES) + assert [t for t, acquired in builds if not acquired] == [] + assert len(mic.scene.slm_events) >= len(STIM_FRAMES) From 6e1c9e3bd07fd0a100c162bfc992ba3648ff1af7 Mon Sep 17 00:00:00 2001 From: hinderling Date: Wed, 5 Aug 2026 23:48:01 +0200 Subject: [PATCH 3/4] fix(moench): stop live acquisition before every MDA; tie KeepDMDAlive to live napari-micromanager can hold a live timer while the stream is already stopped, and that timer restarts live acquisition on the per-frame configSet/exposureChanged signals the engine emits, which then fights every snap. Stop continuous acquisition at MDA start without gating on isSequenceRunning(), since the emitted sequenceAcquisitionStopped is what clears the timer. Doing it in the engine covers calibration and bare mmc.mda.run() as well as experiments run through the Controller. Drive KeepDMDAlive from the live-acquisition signals rather than starting it at boot and bracketing every MDA: run() on live start (now idempotent), stop() on live stop (now a no-op when idle). The engine drives the DMD every event during a run, so no keep-alive is needed and there is no stop/restart pair left to race on a cancelled run. --- faro/microscope/pertzlab/moench.py | 113 ++++++++++-------- tests/hardware/pertzlab/test_pertzlab_unit.py | 75 +++++++++++- 2 files changed, 136 insertions(+), 52 deletions(-) diff --git a/faro/microscope/pertzlab/moench.py b/faro/microscope/pertzlab/moench.py index b3ac41c..dee11ff 100644 --- a/faro/microscope/pertzlab/moench.py +++ b/faro/microscope/pertzlab/moench.py @@ -68,8 +68,17 @@ def wakeup_dmd(self): # periodic refresh instead of being forced back to all-on. self.dmd.display_livemode() - def run(self): + def run(self, *_): + """Start the refresh thread; no-op if it is already running. + + Connected to ``continuousSequenceAcquisitionStarted``, which fires + again every time live view re-arms (napari does so on config and + exposure changes), so repeated calls must not spawn extra threads. + ``*_`` absorbs the signal's camera-label payload. + """ _set_c_numeric_locale() + if self.thread is not None and self.thread.is_alive(): + return self._stop_event.clear() self.last_wakeup = 0.0 self.thread = threading.Thread(target=self._run, daemon=True) @@ -86,10 +95,18 @@ def _run(self): if self._stop_event.wait(timeout=5): return - def stop(self): + def stop(self, *_): + """Stop the refresh thread and reset the SLM; no-op if not running. + + Connected to ``sequenceAcquisitionStopped``, which also fires when the + MDA stops a live stream that was never running, so the idle case must + leave the SLM alone. ``*_`` absorbs the signal's camera-label payload. + """ _set_c_numeric_locale() + if self.thread is None: + return self._stop_event.set() - if self.thread is not None and self.thread.is_alive(): + if self.thread.is_alive(): self.thread.join() self.thread = None self.mmc.setSLMExposure(self.mmc.getSLMDevice(), 100) @@ -222,7 +239,14 @@ def init_scope(self): affine_matrix=self.affine_calibration_matrix, ) self.wakeup_dmd = KeepDMDAlive(self.mmc, self.dmd) - self.wakeup_dmd.run() + # The keep-alive refresh only matters while live view is running. + # During an MDA the engine drives the DMD on every event and the hold + # exposure spans the inter-frame gaps, so tying the thread to the + # live-acquisition signals keeps it off for the whole run. + self.mmc.events.continuousSequenceAcquisitionStarted.connect( + self.wakeup_dmd.run + ) + self.mmc.events.sequenceAcquisitionStopped.connect(self.wakeup_dmd.stop) self.image_height = self.mmc.getImageHeight() self.image_width = self.mmc.getImageWidth() @@ -263,19 +287,18 @@ def calibrate_dmd( return def _do_calibration() -> None: - self.wakeup_dmd.stop() - try: - self.dmd.calibrate( - calibration_channel, - verbose=verbose, - n_points=n_points, - radius=radius, - exposure=exposure, - marker_style=marker_style, - calibration_points_DMD=calibration_points_DMD, - ) - finally: - self.wakeup_dmd.run() + # The calibration MDA's setup_sequence stops live acquisition, + # which stops KeepDMDAlive, and the engine then drives the DMD + # itself for every calibration event. + self.dmd.calibrate( + calibration_channel, + verbose=verbose, + n_points=n_points, + radius=radius, + exposure=exposure, + marker_style=marker_style, + calibration_points_DMD=calibration_points_DMD, + ) if not background: _do_calibration() @@ -334,14 +357,18 @@ def _teardown_hardware(self) -> None: The wakeup thread keeps a reference to the SLM device; stopping it before ``unloadAllDevices`` avoids the unload racing the - thread's next ``displaySLMImage`` call. + thread's next ``displaySLMImage`` call. Its live-acquisition + connections go first so no late signal touches the SLM during unload. """ wakeup = getattr(self, "wakeup_dmd", None) if wakeup is not None: - try: + with suppress(Exception): + self.mmc.events.continuousSequenceAcquisitionStarted.disconnect( + wakeup.run + ) + self.mmc.events.sequenceAcquisitionStopped.disconnect(wakeup.stop) + with suppress(Exception): wakeup.stop() - except Exception: - pass super()._teardown_hardware() def register_engine(self, force: bool = False) -> None: @@ -693,35 +720,27 @@ def _wait_for_system_excluding_xy(self, event: MDAEvent) -> None: ) def setup_sequence(self, sequence): - """Pause KeepDMDAlive for the duration of the MDA. - - The engine drives the DMD on every event (stim mask on stim - frames, all-on on imaging frames when - ``dmd_needs_to_be_waken``), so the 60 s background refresh is - redundant during a run and just adds SLM-device contention. - Restarted in ``teardown_sequence``. + """Stop live acquisition before the MDA starts. + + This runs before the first event, so it only ever stops a live + preview, never a real hardware sequence. It is not gated on + ``isSequenceRunning()``: napari-micromanager can hold a live timer + while the stream is already stopped, and that timer restarts live + acquisition on the per-frame configSet/exposureChanged signals this + engine emits, which then fights every snap. The emitted + ``sequenceAcquisitionStopped`` is what clears that timer, and it also + stops ``KeepDMDAlive`` for the duration of the run. The Controller + stops live too; doing it here also covers calibration and bare + ``mmc.mda.run()`` calls. """ - mic = self.microscope - if mic is not None: - wakeup = getattr(mic, "wakeup_dmd", None) - if wakeup is not None: - try: - wakeup.stop() - except Exception: - self._log.exception("Failed to stop wakeup_dmd before MDA") + core = getattr(self, "mmcore", None) + if core is not None: + try: + core.stopSequenceAcquisition() + except Exception: + self._log.exception("Failed to stop live acquisition before MDA") return super().setup_sequence(sequence) - def teardown_sequence(self, sequence) -> None: - super().teardown_sequence(sequence) - mic = self.microscope - if mic is not None: - wakeup = getattr(mic, "wakeup_dmd", None) - if wakeup is not None: - try: - wakeup.run() - except Exception: - self._log.exception("Failed to restart wakeup_dmd after MDA") - def setup_event(self, event: MDAEvent) -> None: """Override to wait for devices individually, bypassing TIXYDrive. diff --git a/tests/hardware/pertzlab/test_pertzlab_unit.py b/tests/hardware/pertzlab/test_pertzlab_unit.py index 453e3d6..82dd4d5 100644 --- a/tests/hardware/pertzlab/test_pertzlab_unit.py +++ b/tests/hardware/pertzlab/test_pertzlab_unit.py @@ -2,7 +2,7 @@ Lives under ``tests/hardware/pertzlab/`` because its subjects are Pertzlab-scope-only: power-property mappings, ``SKIP_WAIT_DEVICES``, -filter-turret verification, and the Moench engine's SLM uploads. +filter-turret verification, and the Moench engine's DMD/SLM handling. These do **not** require a real scope and are **not** marked ``@pytest.mark.hardware``; they run in every test session. @@ -14,11 +14,11 @@ import numpy as np import pytest -from useq import MDAEvent +from useq import MDAEvent, MDASequence from faro.core._useq_compat import SLMImage from faro.core.data_structures import Channel, PowerChannel -from faro.microscope.pertzlab.moench import MoenchMDAEngine +from faro.microscope.pertzlab.moench import KeepDMDAlive, MoenchMDAEngine from tests.fake_mmc import build_core @@ -345,9 +345,8 @@ def test_device_not_loaded_is_noop(self): assert mmc.getState_calls == 0 assert mmc.setStateLabel_calls == [] - # =================================================================== -# SLM uploads +# DMD/SLM uploads, live-stop, and keep-alive lifecycle # =================================================================== @@ -412,3 +411,69 @@ def test_array_is_passed_through_unchanged(self, slm_core): assert [name for name, _ in calls] == ["setSLMImage"] assert np.array_equal(calls[0][1], mask) + + +class TestSetupSequenceStopsLive: + """Every MDA stops live acquisition first, whether or not it is running. + + A viewer can keep a live timer armed after the stream itself has stopped, + and only the ``sequenceAcquisitionStopped`` signal clears it. + """ + + def test_stops_live_when_nothing_is_running(self, slm_core): + stopped = [] + slm_core.events.sequenceAcquisitionStopped.connect( + lambda *args: stopped.append(args) + ) + engine = MoenchMDAEngine(slm_core) + + assert not slm_core.isSequenceRunning() + engine.setup_sequence(MDASequence()) + + assert stopped, "sequenceAcquisitionStopped must fire even when idle" + + +class TestKeepDMDAliveLifecycle: + """The keep-alive thread runs while live acquisition does, and not longer. + + It refreshes the DMD so the mirrors do not park during live view. An MDA + drives the DMD itself, so the thread must be off for the whole run. + """ + + def _keep_alive(self, core): + displays = [] + dmd = SimpleNamespace(display_livemode=lambda: displays.append(1)) + return KeepDMDAlive(core, dmd), displays + + def test_repeated_run_does_not_spawn_a_second_thread(self, slm_core): + keep_alive, _ = self._keep_alive(slm_core) + try: + keep_alive.run() + first = keep_alive.thread + keep_alive.run() + assert keep_alive.thread is first + finally: + keep_alive.stop() + + def test_stop_while_idle_leaves_the_slm_alone(self, slm_core): + keep_alive, _ = self._keep_alive(slm_core) + displayed = [] + slm_core.displaySLMImage = lambda *args: displayed.append(args) + + keep_alive.stop() + + assert displayed == [] + + def test_live_signals_drive_the_thread(self, slm_core): + """Live start runs the thread, live stop stops it.""" + keep_alive, _ = self._keep_alive(slm_core) + slm_core.events.continuousSequenceAcquisitionStarted.connect(keep_alive.run) + slm_core.events.sequenceAcquisitionStopped.connect(keep_alive.stop) + try: + slm_core.events.continuousSequenceAcquisitionStarted.emit("Camera") + assert keep_alive.thread is not None + + slm_core.events.sequenceAcquisitionStopped.emit("Camera") + assert keep_alive.thread is None + finally: + keep_alive.stop() From b7495c11ebbdcc72460e0d378a9cfaf98920b255 Mon Sep 17 00:00:00 2001 From: Hinderling Date: Fri, 7 Aug 2026 16:18:10 +0200 Subject: [PATCH 4/4] fix(dmd): run calibration spots in a single MDA Projecting the calibration/test spots with one mmc.mda.run per event brackets each spot with setup/teardown_sequence, which stop and restart KeepDMDAlive; each restart re-displays the all-on live pattern and resets the SLM ExposureTime, so under OverlapMode a spot can end up on a short exposure that blanks before the camera opens. Run every event in one MDA (with hardware sequencing off -- otherwise pymmcore-plus tries to combine the consecutive SLM events and fails validation) so KeepDMDAlive pauses exactly once. --- faro/core/dmd.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/faro/core/dmd.py b/faro/core/dmd.py index d938e15..778d73f 100644 --- a/faro/core/dmd.py +++ b/faro/core/dmd.py @@ -2,7 +2,6 @@ import numpy as np import numpy.typing as npt import matplotlib.pyplot as plt -import time import scipy # from .acquisition import acq @@ -227,6 +226,31 @@ def select_well_distributed_points(self, valid_pixels, n_points): return selected_points + def _run_events_unsequenced(self, events): + """Run *events* as a single MDA with hardware sequencing disabled. + + A separate ``mmc.mda.run`` per event brackets each one with + setup/teardown_sequence, which stop and restart ``KeepDMDAlive``; each + restart re-displays the all-on live pattern and resets the SLM + ExposureTime, so under OverlapMode a spot can end up on a short exposure + that blanks before the camera opens. Running every event in one MDA + pauses ``KeepDMDAlive`` just once. + + Sequencing must be off: with it on, pymmcore-plus tries to + hardware-combine the consecutive SLM events into an ``slm_sequence`` and + fails validation (``SLMImage`` is not ``bytes``). With it off, the + events still run one at a time within the single MDA. + """ + engine = getattr(self.mmc.mda, "engine", None) + prev = getattr(engine, "use_hardware_sequencing", None) + if engine is not None: + engine.use_hardware_sequencing = False + try: + self.mmc.mda.run(events) + finally: + if engine is not None and prev is not None: + engine.use_hardware_sequencing = prev + def calibrate( self, calibration_channel, @@ -301,9 +325,7 @@ def calibrate( def _collect_calibration_frame(img: np.ndarray, event: MDAEvent): calibration_images.append(img) - for event in events: - self.mmc.mda.run([event]) - time.sleep(0.1) + self._run_events_unsequenced(events) self.mmc.mda.events.frameReady.disconnect(_collect_calibration_frame) calibration_images = np.array(calibration_images) @@ -396,9 +418,7 @@ def _collect_calibration_frame(img: np.ndarray, event: MDAEvent): def _collect_test_frame(img: np.ndarray, event: MDAEvent): test_image.append(img) - for event in events: - self.mmc.mda.run([event]) - time.sleep(0.5) + self._run_events_unsequenced(events) self.mmc.mda.events.frameReady.disconnect(_collect_test_frame) calibration_images = np.array(calibration_images) for img in test_image: