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/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: diff --git a/faro/microscope/pertzlab/moench.py b/faro/microscope/pertzlab/moench.py index 92478d7..dee11ff 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 @@ -66,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) @@ -84,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) @@ -220,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() @@ -261,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() @@ -332,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: @@ -691,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. @@ -780,7 +801,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 +812,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 +841,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..82dd4d5 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 DMD/SLM handling. 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, MDASequence +from faro.core._useq_compat import SLMImage from faro.core.data_structures import Channel, PowerChannel +from faro.microscope.pertzlab.moench import KeepDMDAlive, MoenchMDAEngine + +from tests.fake_mmc import build_core # =================================================================== @@ -341,3 +344,136 @@ def test_device_not_loaded_is_noop(self): assert mmc.getState_calls == 0 assert mmc.setStateLabel_calls == [] + +# =================================================================== +# DMD/SLM uploads, live-stop, and keep-alive lifecycle +# =================================================================== + + +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) + + +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() 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)