Follow-ups from the Moench hardware test sweep (2026-04-09). The two
hardware smoke tests (tests/hardware/test_line_stimulation.py and
tests/hardware/test_cell_migration.py) now pass cleanly on Moench:
| Test | Duration | Zombies | BG errors |
|---|---|---|---|
test_line_stimulation_smoke |
52.2 s | 0 | 0 |
test_cell_migration_smoke |
280.2 s | 0 | 0 |
The items below are issues the hardware run surfaced but that we haven't fixed yet.
Status. Fixed via a SKIP_WAIT_DEVICES: tuple[str, ...] class
attribute on Moench that MoenchMDAEngine._wait_for_system_excluding_xy
consults via the engine's microscope weakref. Moench declares
SKIP_WAIT_DEVICES = ("Mosaic3",). Unit-tested in
tests/test_hardware_pertzlab.py::TestSkipWaitDevices. Verification
of the actual time saved on a cell-migration test run is still
pending — expected savings 60-100 s on a 280 s test (5 s per event
× ~12-20 events that hit the wait path).
Upstream follow-up. A more general per-device timeout primitive
on CMMCorePlus.waitForDevice(dev, timeout_ms=X) — where
timeout_ms=0 collapses to "check Busy() once, fail fast" — would
replace the skip-list with a cleaner device-neutral knob. See
../pymmcore-plus/TODO.md for the full proposal and MMCore C++
source references.
Symptom. On the first stim timestep of test_cell_migration_smoke:
Warning: Stimulation mask not ready (timeout):
Warning: Stimulation mask unavailable, sending False to SLM.
The stim event fires but the SLM gets False (no pattern), so no
actual stimulation happens on that frame — yet the experiment keeps
going as if it had stimulated. This is the worst kind of silent bug
for a 24 h feedback experiment: you'd believe stim was on from frame
1 and only discover the gap during analysis.
Status. The silent half is fixed: Analyzer.get_stim_mask now
records a stim_mask background error on timeout, so hardware tests
assert on it and fail loudly. The stim event still sends False to
the SLM (log-and-continue, same as other background failures), but
at least the failure is visible.
Root cause is UNVERIFIED. Earlier notes claimed "cellpose GPU warmup > 80 s" but nobody has actually measured it. Plausible alternatives:
- Cellpose-SAM first-inference GPU cold start. The v4 default
model is
cpsam, ~1.2 GB (vs. ~25 MB for older cellpose models). First-inference cold start with cuDNN autotuning on a SAM-scale graph can be 30–90 s. - Weights download on first-ever run. Cellpose fetches to
~/.cellpose/models/lazily. If that dir is empty, 1.2 GB at lab-WiFi speeds is 60+ s once, then never again. If this is the cause, the "fix" is a one-time pre-download, not a code change. - A pipeline task raising an exception that's eaten before
stim_mask_queue.put()runs. (_pipeline_task_donenow records tobackground_errors, so this case should already be visible — if the test reports apipelineerror alongside thestim_maskerror, this is the cause.) - Deadlock between the storage worker and the pipeline executor.
- The first raw imaging frame never making it through
Controller._frame_buffers(wrong channel count, stale buffer, buffer-shape-mismatch path triggered, etc.).
Don't add more speculation — instrument and collect data. All of
these should be gated behind analyzer.debug so normal runs aren't
affected.
CellposeV4.__init__wall time. Time themodels.CellposeModel(gpu=gpu)construction separately from first inference. Tells us whether the problem is model loading or inference.CellposeV4.segmentfirst N calls. Keep a per-instance call counter and print[timing] cellpose.segment call=N t=X.Xsfor the first 3 calls. If call 1 is 70 s and call 2 is 0.3 s, it's first-inference warmup.- Executor-queue lag. In
_try_submit_pipeline, print the wall-clock gap betweenexecutor.submit(...)and the first line ofpipeline.run(add a timestamp param, or instrumentpipeline.rundirectly). Rules out "executor queue backlog". stim_mask_queue.put()timing. Print when the first stim mask actually gets put. The gap between the first_on_frame_readyand the correspondingqueue.putis the actionable number.- Confirm weights cache state before the run.
ls -lh ~/.cellpose/models/on the Moench PC before starting the test. Ifcpsamis missing or zero-bytes, the first run is a download, not inference.
- If first-inference warmup: add a
pipeline.prewarm()hook that runs onesegment(np.zeros((256, 256), dtype=np.uint16))insideController.run_experimentbeforerun_mda. Consider running it on a thread that fans out in parallel with MDA startup so it doesn't add serial wall-time. - If weights download: document "pre-download cellpose weights
before running hardware tests" in the
tests/hardware/conftest.pydocstring, or add a session-scope fixture that instantiatesCellposeModel(gpu=True)once to force the download. - If executor queue lag or deadlock: fix the actual bug — pre-warming won't help.
- If a pipeline exception is being eaten: find the missing
_record_background_errorcallsite and add it.
80 s is hardcoded in Analyzer.get_stim_mask. Pipelines with slow
first-frame segmenters (cellpose SAM, stardist remote, convpaint)
need more. Add it as a constructor arg on Analyzer, or let
Controller pass it through from run_experiment. Not urgent now
that the background-error recording surfaces the timeout either way.
Current. test_cell_migration_smoke runs in 280 s. The test
docstring claims "about a minute". User target is ≤ 3 min.
What drives the time.
waitForDevice(Mosaic3)~60 s (see #1)- First-frame stim mask timeout ~80 s (see #2)
- Cellpose GPU model load ~20–30 s
- 4 frames × 5 s time plan = 20 s
- Rest: imaging, stage, ref frames, teardown
Fixing #1 + #2 should drop this to ~90–120 s, comfortably inside 3
min. Until then, consider marking the test as @pytest.mark.slow so
it isn't blocking on the 3-min budget.
This isn't a bug — it's a convention the hardware sweep established and that future changes should respect.
Rule. Camera binning is a global acquisition-wide setting.
The zarr writer allocates one (y, x) shape per store from
mmc.getImageHeight() / getImageWidth() at init_stream time, so
any mid-run binning change corrupts every frame downstream.
Where binning lives on Moench. TiMoench.cfg has a dedicated
Binning config group with 1x1 / 2x2 / 4x4 presets. Callers
pick one with:
mic.mmc.setConfig("Binning", "2x2")Where it must NOT live. Channel presets in TTL_ERK (or any
other light-path group) must not set Camera,Binning,*. Previously
mCitrine carried Camera,Binning,2x2 and no other channel reset
it, so one mCitrine capture stuck the camera at 2×2 for the rest of
the run. This caused the cascade of broadcast errors that kicked off
the whole sweep.
Test fixture default. tests/hardware/conftest.py applies
Binning,2x2 before set_roi() — most MM configs reset the ROI
on a binning change, so binning has to happen first. The fixture
then re-asserts the scope's ROI on top.
Notebooks. experiments/22_line_stimulation/line_stimulation.ipynb
and experiments/21_cell_migration/cell_migration.ipynb both call
mic.mmc.setConfig("Binning", "2x2") in the ROI cell — update the
argument (or add a new cell earlier) when you want a different
binning for that experiment.
If you add a new Pertzlab microscope cfg, mirror this structure:
keep Binning as its own group, and never let a channel preset
touch camera geometry properties (Binning, ROI, PixelSize).
- Moench
post_experiment()currently starts the DMD wakeup thread rather than stopping it. That's fine between back-to-back experiments, but is a confusing name. Considerprepare_for_next_experiment()or split into two methods. _record_background_errortakes an exception and readstraceback.format_exc()— which only works if it's called from an activeexceptblock. The storage worker and deferred worker already guarantee that, but if we ever record from a non-exceptcontext the traceback field will be stale. Worth a comment.- The test assertion message prints the full traceback blob inline
(visible in
assertion output). That's noisy — consider formatting only the exception type + message in the assertion string and logging the full tracebacks separately.