Repository navigation
feat: Record + Z-Stack acquisition mode - #564
hongquanli wants to merge 115 commits into
Conversation
Hardware test planSimulation testing (GUI driven end-to-end, Zarr outputs verified — happy path, validation, Copy-from-Live, mid-recording abort, Setup
Tests
Inspecting outputsPer-FOV shapes: recording import json, glob
for p in glob.glob("<experiment_dir>/**/zarr.json", recursive=True):
m = json.load(open(p))
if m.get("node_type") == "array":
print(p, m["shape"], json.load(open(p)).get("attributes", {}).get("_squid", {}))Log signals worth watching: Camera-only bench (no assembled scope)If you only have the ToupTek camera on a dev machine, Squid's per-component simulation covers most of the list above. In [SIMULATION]
simulate_camera = false ; real ToupTek camera
simulate_microcontroller = true ; simulated MCU, stage, illumination
simulate_spinning_disk = true
simulate_filter_wheel = true
simulate_objective_changer = true
simulate_laser_af_camera = trueThen launch without What this bench covers, in value order:
Still instrument-only after this bench: illumination energize (dark-frame check), hardware-trigger interplay, laser AF, real stage-settle timing (tests 1, 3, 4 above). 🤖 Generated with Claude Code |
Task review findings (PR #564): - Recording channel exp/gain/illum spinboxes were set unconditionally even when the YAML channel name wasn't found in the current combo, silently pairing a stale display name with mismatched numeric settings. Now only applied when found; a warning is logged otherwise and the row is left untouched. - checkbox_time was never synced from yaml_data.nt, leaving the Time checkbox unchecked (and time_controls_frame hidden) after loading a multi-timepoint YAML even though entry_Nt/entry_dt were updated. Now checkbox_time is blocked/set like the sibling WellplateMultiPointWidget, and the frame's visibility is refreshed directly in the finally block (not via _on_time_toggled, which would clobber the just-loaded Nt/dt via its stored/restore side effect). Adds two regression tests in tests/test_record_zstack_widget.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…work Merges origin/feat/record-zstack-acquisition (18 commits: widget layout changes + Record/Z-Stack settings reuse via YAML + drag-and-drop) into feat/multi-plane-recording (#579) so the stacked PR is unblocked. Conflicts resolved in: - control/core/record_zstack_controller.py: kept both the multi-plane recording_plane_offsets_um() helper and the settings-reuse _build_objective_info()/_save_record_zstack_yaml() helpers. - control/widgets.py: kept HEAD's Nz/dz/bottom-Z recording-plane redesign (which superseded the single entry_recording_z_offset field) and dropped the now-redundant old Z-offset widget from the other side's layout tweaks; updated _apply_yaml_settings() and its widgets_to_block list (a non-conflicting hunk that still referenced the removed widget/field) to use the new Nz/bottom-Z/dz widgets. - tests/core/test_record_zstack_worker.py and tests/test_record_zstack_widget.py: unioned both branches' new test functions (git's diff3 had misaligned several of them because they shared boilerplate setup code); updated the handful of assertions that referenced the pre-multi-plane recording_z_offset_um field. Also renamed RecordZStackYAMLData.recording_z_offset_um -> recording_bottom_z_offset_um + new recording_nz/recording_dz_um fields (and the corresponding acquisition.yaml recording: keys) in control/acquisition_yaml_loader.py, since the settings-reuse YAML schema needs to round-trip the multi-plane fields that superseded the single z-offset -- this wasn't flagged as a textual conflict but broke without the update. Updated tests/control/test_acquisition_yaml_loader.py to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a new Record + Z-Stack acquisition mode to the HCS GUI, enabling per-FOV sequential acquisition of (1) a continuous recording phase saved as per-FOV Zarr and (2) a multi-channel Z-stack saved as OME-Zarr, with shared acquisition mechanics refactored into a reusable worker base.
Changes:
- Introduces a recording pipeline (
StreamingCapture) with bounded backpressure and integrity sealing for incomplete captures. - Adds
RecordZStackController/RecordZStackWorkerplus GUI wiring for a new “Record + Z-Stack” tab. - Refactors shared capture/job-dispatch mechanics into
MultiPointWorkerBaseand adds a best-effort camera FPS hint API (set_frame_rate) including Toupcam support.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| software/control/acquisition_yaml_loader.py | Extends YAML parsing to support widget_type: record_zstack. |
| software/control/camera_toupcam.py | Adds Toupcam set_frame_rate() support and continuous-mode max FPS calculation. |
| software/control/core/acquisition_setup.py | Introduces shared helpers for pixel size calculation and experiment directory creation. |
| software/control/core/multi_point_controller.py | Uses shared experiment-dir creation helper. |
| software/control/core/multi_point_worker.py | Refactors shared acquisition mechanics into MultiPointWorkerBase. |
| software/control/core/record_zstack_controller.py | Adds controller for Record + Z-Stack acquisition setup/threading and YAML snapshotting. |
| software/control/core/record_zstack_worker.py | Adds worker implementing per-FOV recording + z-stack sequencing. |
| software/control/core/streaming_capture.py | Adds recording primitives (frame source/router/stop condition/writer) with bounded queues. |
| software/control/core/zarr_writer.py | Improves event-loop handling and expands abort sealing to distinguish incomplete vs aborted runs. |
| software/control/gui_hcs.py | Wires new Record + Z-Stack tab/widget/controller into the HCS GUI and well-selector behavior. |
| software/squid/abc.py | Adds AbstractCamera.set_frame_rate() API (default no-op returning max achievable). |
| software/squid/camera/utils.py | Implements set_frame_rate() and continuous pacing behavior in the simulated camera. |
| software/tests/control/test_HighContentScreeningGui.py | Adds GUI tests ensuring well-selector behavior on the new tab. |
| software/tests/control/test_acquisition_yaml_loader.py | Adds YAML loader tests for record_zstack acquisitions. |
| software/tests/core/init.py | Test package init (structure/support for new core tests). |
| software/tests/core/test_record_zstack_worker.py | Adds worker/controller smoke + behavior tests for Record + Z-Stack acquisition. |
| software/tests/core/test_streaming_capture.py | Adds extensive unit tests for the recording pipeline and its failure modes. |
| software/tests/test_acquisition_yaml_drop_mixin.py | Adds tests for YAML drop mixin generalizations supporting the 3rd widget type. |
| software/tests/test_record_zstack_camera_fps.py | Adds tests for camera FPS hinting/clamping (sim + Toupcam math). |
Comments suppressed due to low confidence (1)
software/control/acquisition_yaml_loader.py:133
- parse_acquisition_yaml() is annotated/documented to return AcquisitionYAMLData, but it now returns RecordZStackYAMLData when widget_type == "record_zstack". This makes the public API misleading and can break type-aware tooling (and any downstream code that relies on AcquisitionYAMLData-specific fields without checking widget_type first).
def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData:
"""Parse acquisition YAML file and return structured data.
Args:
file_path: Path to the acquisition.yaml file
Returns:
AcquisitionYAMLData with parsed values
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| period_s = self._exposure_time_ms / 1000.0 | ||
| if self._target_frame_period_s is not None: | ||
| period_s = max(period_s, self._target_frame_period_s) | ||
| time_since = time.time() - last_frame_time | ||
| # use self._exposure_time and _acquisition_mode so as not to spam the logs, | ||
| # but this could case issues if subclassed for testing. | ||
| if ( | ||
| self._exposure_time_ms / 1000.0 | ||
| ) - time_since <= 0 and self._acquisition_mode == CameraAcquisitionMode.CONTINUOUS: | ||
| if time_since >= period_s and self._acquisition_mode == CameraAcquisitionMode.CONTINUOUS: |
| deadline = time.monotonic() + timeout_s | ||
| while True: | ||
| try: | ||
| self._q.put(_SENTINEL, timeout=min(1.0, max(0.1, timeout_s))) | ||
| break |
feat: multi-plane recording — N z-planes per FOV (stacks on #564)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
software/squid/camera/utils.py:312
- SimulatedCamera.set_frame_rate() clamps achievable fps using get_total_frame_time() (exposure + strobe), but the CONTINUOUS streaming loop paces frames using only exposure time. This can make the simulated camera deliver frames faster than the achievable fps it reports, which can mis-size recordings and invalidate tests that rely on achievable-fps behavior.
period_s = self._exposure_time_ms / 1000.0
if self._target_frame_period_s is not None:
period_s = max(period_s, self._target_frame_period_s)
time_since = time.time() - last_frame_time
if time_since >= period_s and self._acquisition_mode == CameraAcquisitionMode.CONTINUOUS:
| timeout_s = (self._exposure_time_ms / 1000.0) * 1.02 + 4 | ||
| deadline = time.time() + timeout_s | ||
| while self._current_frame is None and time.time() < deadline: | ||
| time.sleep(0.001) | ||
| return self._current_frame |
There was a problem hiding this comment.
fixed in lastest master
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
software/squid/camera/utils.py:312
- The simulated camera’s streaming loop paces frames using exposure time only, but set_frame_rate() clamps/returns an achievable rate using get_total_frame_time() (exposure + strobe). This makes the simulated camera’s actual free-run period inconsistent with the frame-time contract used elsewhere (and with the achievable fps that callers compute).
last_frame_time = time.time()
while self._continue_streaming:
period_s = self._exposure_time_ms / 1000.0
if self._target_frame_period_s is not None:
period_s = max(period_s, self._target_frame_period_s)
time_since = time.time() - last_frame_time
if time_since >= period_s and self._acquisition_mode == CameraAcquisitionMode.CONTINUOUS:
| # wrong. | ||
| non_hw_frame_timeout = 5 * self.camera.get_total_frame_time() / 1e3 + 2 | ||
| if not self._ready_for_next_trigger.wait(non_hw_frame_timeout): | ||
| self._log.error("Timed out waiting {non_hw_frame_timeout} [s] for a frame, aborting acquisition.") |
Task review findings (PR #564): - Recording channel exp/gain/illum spinboxes were set unconditionally even when the YAML channel name wasn't found in the current combo, silently pairing a stale display name with mismatched numeric settings. Now only applied when found; a warning is logged otherwise and the row is left untouched. - checkbox_time was never synced from yaml_data.nt, leaving the Time checkbox unchecked (and time_controls_frame hidden) after loading a multi-timepoint YAML even though entry_Nt/entry_dt were updated. Now checkbox_time is blocked/set like the sibling WellplateMultiPointWidget, and the frame's visibility is refreshed directly in the finally block (not via _on_time_toggled, which would clobber the just-loaded Nt/dt via its stored/restore side effect). Adds two regression tests in tests/test_record_zstack_widget.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
0afae24 to
d07b875
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved timing, cleanup, abort, and failure-classification paths can produce incorrect metadata or leave hardware resources in an inconsistent state.
Review effort: Balanced
Findings: 6
Open (7)
Recheck abort state before starting the z-stack · New Always finalize the writer when source stop fails · New Preserve current-Z recording offsets without Laser AF · New SimulatedCamera.read_camera_frame() currently waits only until _current_frame is non-None. If the… RecordingWriter.finalize() is intended to respect a single timeout budget, but the sentinel put()… SimulatedCamera.set_frame_rate() clamps using get_total_frame_time() (exposure + strobe), but the… This log message uses braces literally (not an f-string), so the timeout value never appears in…
| if self.params.zstack_enabled and self.zstack_channels: | ||
| self.zstack(t_idx, region_id, fov_idx, z_ref) |
| # reach writer.enqueue after finalize — harmless with RecordingWriter (the | ||
| # drain thread has exited, so the put times out and the frame is logged as | ||
| # dropped, not corrupted). | ||
| self._source.stop() |
|
|
||
| # ---------------------------------------------------------------------- public API | ||
|
|
Task review findings (PR #564): - Recording channel exp/gain/illum spinboxes were set unconditionally even when the YAML channel name wasn't found in the current combo, silently pairing a stale display name with mismatched numeric settings. Now only applied when found; a warning is logged otherwise and the row is left untouched. - checkbox_time was never synced from yaml_data.nt, leaving the Time checkbox unchecked (and time_controls_frame hidden) after loading a multi-timepoint YAML even though entry_Nt/entry_dt were updated. Now checkbox_time is blocked/set like the sibling WellplateMultiPointWidget, and the frame's visibility is refreshed directly in the finally block (not via _on_time_toggled, which would clobber the just-loaded Nt/dt via its stored/restore side effect). Adds two regression tests in tests/test_record_zstack_widget.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
59de99a to
1d2e72e
Compare
Implements set_frame_rate override on SimulatedCamera to cap the frame rate in continuous acquisition mode. When set_frame_rate is called, the streaming thread's frame cadence gate now honors both exposure time and the target frame period, using whichever is larger. The method returns the effective achievable FPS clamped by the total frame time (exposure + strobe). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Lift shared acquisition mechanics out of MultiPointWorker into a new MultiPointWorkerBase so a future RecordZStackWorker sibling can reuse them: camera/stage/channel-apply, single-frame capture (acquire_camera_image), the camera frame callback (_image_callback) with CaptureInfo handoff + job creation/dispatch + backpressure gating + ready-for-next-trigger event, job-result summarize/finish, move_to_z_level, _select_config, _sleep, _frame_wait_timeout_s, wait_till_operation_is_completed, update_use_piezo. MultiPointWorker now subclasses MultiPointWorkerBase, calls super().__init__() for the shared handles/state, then builds the real job runners / backpressure controller and sets all MultiPoint-specific state (NZ/deltaZ/z_range/ z_stacking_config/use_piezo/selected_configurations/scan coords/time-point/ AF + per-channel-offset). Orchestration (run, run_single_time_point, run_coordinate_acquisition, acquire_at_position) and the z-stack/offset/AF helpers stay in MultiPointWorker unchanged. _emit_plate_layout gets a base no-op stub overridden by MultiPointWorker's real implementation so the lifted frame callback stays self-contained. Method bodies moved verbatim; net MultiPointWorker state is identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….py (no behavior change) Move the experiment-ID timestamping + directory creation logic from MultiPointController.start_new_experiment into a free function create_experiment_dir(base_path, experiment_id) -> (resolved_id, dir_path) in control/core/acquisition_setup.py so a future RecordZStackController can reuse it without duplicating code. Pre-warmed JobRunner methods left in MultiPointController — they mutate instance state and do not factor out cleanly as free functions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orker Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…STACK_DISPLAY_FPS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ings Review findings (sub-threshold but real): the _def.py comment said z-stack planes 'always display' while the next sentence says 0 disables both phases; and _on_frame's 'route + enqueue only' prose predated the preview tap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…um rename #579's multi-plane recording (now merged into feat/record-zstack-acquisition) renamed RecordZStackAcquisitionParameters.recording_z_offset_um to recording_bottom_z_offset_um and added recording_Nz/recording_dz_um. The git merge of feat/record-zstack-acquisition didn't touch these two already-passing tests textually, so they kept constructing the params with the old keyword and failed after the merge.
… runs off its nominal rate; review follow-ups From the high-effort review of the bench-session commits (verifier-confirmed findings; three others refuted): - Paced routing fills slots sequentially and never rejects, so a camera that delivers slower (or faster) than the rate it reported would fill every slot and seal acquisition_complete=True with a wrong time_increment_s — the old nearest-slot router at least left holes. RecordingRouter now measures the delivered spacing ((last - first) / slots spanned); StreamingCapture warns and hands it to the writer when it deviates >2% from nominal, and the seal (finalize or incomplete/abort) rewrites _squid.time_increment_s and the OME t-axis scale with it, keeping nominal_time_increment_s alongside. Within 2% (host-timestamp jitter) the nominal value is kept. - The stall warning logged from the hot camera-callback thread on every stall: now the first and every 50th, plus a per-run summary after the source stops (stall/hole counts on the router). - ToupcamCamera._start_raw_camera_stream refreshes the PRECISE_FRAMERATE range only for a CONTINUOUS stream (a trigger-mode stream from the z-stack phase must not overwrite the free-run range), outside the Start try block, and _refresh_precise_framerate_range swallows any exception — nothing there may desync _raw_camera_stream_started from the SDK stream. - RecordZStackController.run_acquisition rejects recording_enabled with recording_channel=None: the worker probes the fps once and re-applies the recording channel per FOV, so without one the z-stack's last channel would set later FOVs' exposure and the cached rate would be wrong. Bench re-check on the ITR3CMOS26000KMA: 10 fps -> 100/100 and 28 fps max -> 840/840 x 2 FOV, both complete, nominal spacing kept (no deviation warning). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
… live frame-rate write) Found by the user on the bench: with a hardware ROI (1600 or 600 rows) the first frame of every recording was saturated; full-frame recordings were clean. Reproduced on the second camera and isolated to two SDK behaviours: - With a ROI, a PRECISE_FRAMERATE above the full-frame maximum written while stopped is accepted but re-clamped to that old maximum at Start (300 written, 280 read back), so the running-state re-apply in ContinuousFrameSource.start is the only way to reach the rate the ROI exists for (it did: 30 and 40 fps recorded). - A write into the running stream corrupts the frame integrating at that moment (1.8-3.6x over-exposed on the dim camera, saturated on the bright one), delivered as the first frame after the write. Full-frame is immune. Fix: the driver skips the running write when the camera already reports the value (no glitch in the common case) and timestamps the writes it does make (frame_rate_hint_live_write_ts); ContinuousFrameSource then discards frames delivered within one period (+20 ms) of that write - the corrupted frame and at most one clean in-flight one - before routing. Verified on camera 2 for full frame, 1600 and 600 rows, with live view continuous / software trigger / off: first frames identical to the rest; 1-2 frames discarded only when a ROI forced the live write, none otherwise. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
…h the requested fps; record requested vs effective fps - AbstractCamera.get_max_frame_rate(exposure_time_ms=None): a pure query for the highest free-run rate at that exposure (base: exposure + strobe; toupcam: readout-limited estimate for the current binning / ROI / bit depth, bounded by the cached PRECISE_FRAMERATE maximum for that mode — within ~1% of the SDK's own maximum on the bench: 28.0 full frame, 91.5 vs 90.9 at a 600-row ROI). - The Confirm Recording dialog now says, when the requested fps exceeds it, what the camera can deliver at the recording exposure, the rate and frame count the run will really use, and that the metadata will carry that rate; the same text is logged. A camera that cannot estimate leaves the dialog unchanged. - The recording store's _squid attributes carry requested_fps and effective_fps alongside time_increment_s (which was already sized from the achievable rate; a measured deviation is stamped separately). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
… starts The rate a recording will run at is now fixed in RecordZStackController.run_acquisition, before anything starts: the request bounded by camera.get_max_frame_rate() at the recording exposure (resolve_effective_fps). It lands in params.effective_fps and in acquisition.yaml (recording.effective_fps next to the requested fps), and the confirmation dialog shows the same number. The worker applies that rate at each FOV; the camera's reply only decides pacing vs. downsampling, and a camera reporting >1% less than resolved is logged and honoured as a safety net. Previously the worker discovered the achievable rate at the first FOV, so the settings file could not record it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
Rebasing this branch onto master (f3bca2c) replays its 102 commits linearly, which drops `Merge origin/master` (5cab654) and the conflict resolutions it carried. Replaying the older side of each conflicting pair reverted master-side work in the files both sides touch. This restores the tree to exactly what `git merge origin/master` produces from the pre-rebase tip (59de99a), with the two conflicts that merge has resolved as follows: - multi_point_worker.py - master (#652) passes `save_settings` to every save job from `_create_job()`, a method this branch had moved into MultiPointWorkerBase. The settings snapshot now lives in the base `__init__` and the base `_create_job()` passes it, so the Record + Z-Stack worker's z-stack jobs get the same settings as multipoint's. - gui_hcs.py - master (#627) removed the napari live view. Keep master's display wiring and add the record-zstack preview connection to the one remaining (pyqtgraph) path; the napari-live variant of it is gone. Also restored, from the dropped merge commit: - acquisition_setup.py, multi_point_controller.py - `add_timestamp` on `create_experiment_dir()` / `start_new_experiment()` (fluidics runner). - gui_hcs.py - `_setup_fluidics_widgets()` call; no refresh of the removed `multiPointWithFluidicsWidget`. - acquisition_yaml_loader.py - record_zstack YAMLs read wellplate-style regions from coordinates.csv. - tests - matching imports and path-separator handling. Verified: this branch's tree is byte-identical to the merge of origin/master into the pre-rebase tip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eview rounds (no behavior change) Cleanup pass over the Record + Z-Stack code. Acquisition behavior, files written and metadata are unchanged. Duplication - widgets: one builder for the channel table and one for its exposure / gain / illumination editors, used by the recording row and the z-stack rows; one lookup of a z-stack row by channel name; one setter for the recording row; the XY / Time tab styles are built from their colour. - zarr_writer: finalize() and abort() share one read-modify-write of zarr.json (_seal_metadata). The two copies had drifted: abort() assigned the attributes under the time-increment branch. - cameras: the free-run maximum frame rate is computed in one place per camera class (get_max_frame_rate / _continuous_max_framerate) and set_frame_rate uses it. - streaming_capture: the writer's drop and write-error accounting is written once; finalize_wedged reads the event that was set with it. - worker: the once-per-acquisition frame-rate setup of record() is its own method (_apply_recording_rate). - tests: the two preview tests use the harness the other worker tests use. Probing of members that always exist - StreamingCapture calls its router, stop condition and writer directly instead of looking for their members with getattr / hasattr; the test writers implement the members the real writer has. - worker, controller, widget and gui_hcs read LiveController, Microscope, ObjectiveStore and ScanCoordinates attributes directly. Dead code - RecordingRouter.paced (never read) and its two stall parameters (never passed; now class constants); the controller's _worker attribute; the worker's fallback channel name and colour for a recording without a channel, which the controller rejects; re-assignments of values the base class already set; unused imports in record_zstack_worker. One efficiency change - A well selection change keeps the wells already tiled and tiles only the new ones, as WellplateMultiPointWidget does, instead of tiling every selected well again on each step of a drag-select. Changes of size, overlap, shape or mode, and the start of an acquisition, still rebuild. Comments and docstrings that described an earlier state of the code are brought up to date. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n Laser AF or Nz changes With the Recording phase unchecked, its section is collapsed by hiding every field in it. The rule that shows the Z offset with Laser AF and dz with Nz > 1 (_update_recording_planes_ui) set those fields visible without looking at the phase checkbox, so ticking Laser AF brought the Z offset back inside the collapsed section. Loading settings with the recording phase off and Laser AF on (or Nz > 1) did the same, because the rule runs after the collapse there. The rule now shows a field only while the Recording phase is checked. Expanding the section shows the fields the rule placed in its rows, as before. Tests (both failed before the fix): toggling Laser AF and Nz with the section collapsed, then expanding it; loading settings with the recording phase off. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The z-stack is saved by the InProcessZarrRunner that the stacked branch added for multipoint, so nothing is forked after a recording has used tensorstore in this process; the abort that silently lost the z-stack of acquisition 3+ cannot happen. RecordZStackController drops its pre-warm slot (no subprocess to warm), the worker polls the save results after every FOV and ends the run as an error on a failed write, and the base worker initializes _run_state_fatal so a subclass's _finish_jobs can seal a crashed run's stores as aborted. The USE_MULTIPROCESSING=False workarounds in the tests, which only existed to dodge the fork abort, are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1d2e72e to
3442c90
Compare
_build_simulated_microscope wrote its 64x48 crop into the module-level CameraConfig that every Microscope.build_from_global_config() camera shares, so every microscope built later in the same pytest process reported 64x48 frames: running tests/core before tests/control broke test_multi_point_controller_disk_space_estimate (the full-suite order hid it). The helper now gives its camera a model_copy with the crop, and a test pins that the shared config is left untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The per-FOV save-result poll drains every finished result (non-blocking) instead of one per FOV, and no longer re-derives the abort decision that _summarize_job_result already takes. - The record-zstack tests share one simulated-setup prologue; the _squid attribute reader and the failing-write stub move to tests/control/test_stubs so the multipoint and record-zstack tests use the same ones. - Docstring and header comment no longer mention a JobRunner for the z-stack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Outstanding lifecycle, pacing, cleanup, and watchdog gaps can leave stores unsealed or acquisitions incorrectly reported.
12 open findings
Routing mode uses stale pre-start frame-rate result · New Finalize returns before in-flight writes complete · New Record and Z-Stack path omits RunStateWriter lifecycle · New Early illumination failure skips hardware-state restoration · New Camera leaks when stream setup fails · New Preserve current-Z recording offsets without Laser AF Always finalize the writer when source stop fails Recheck abort state before starting the z-stack SimulatedCamera.read_camera_frame() currently waits only until _current_frame is non-None. If the… RecordingWriter.finalize() is intended to respect a single timeout budget, but the sentinel put()… SimulatedCamera.set_frame_rate() clamps using get_total_frame_time() (exposure + strobe), but the… This log message uses braces literally (not an f-string), so the timeout value never appears in…
🧠 Review effort: Balanced
| # A camera that reports it will deliver at (or below) the target | ||
| # rate delivers only wanted frames: route sequentially. A camera | ||
| # that reports a HIGHER rate free-runs faster than the target (no | ||
| # hardware pacing) and must be downsampled by arrival time. | ||
| paced = bool(achievable_fps) and achievable_fps <= target_fps * (1 + 1e-6) |
There was a problem hiding this comment.
[Claude Code] Fixed in 6c36b1a - ContinuousFrameSource.start() now hands the rate the camera reports with the stream running to RecordingRouter.set_paced_from_rate(), so a rejected stopped-state write (free-run maximum reported) no longer leaves an at-rate sensor on nearest-slot routing. Tests: test_frame_source_corrects_the_router_pacing_from_the_running_stream_rate, test_recording_switches_to_paced_routing_when_the_camera_only_paces_once_streaming.
There was a problem hiding this comment.
[Claude Code] Simplified in 54eb11c - the regime is now decided once, by StreamingCapture, from the rate ContinuousFrameSource.start() returns with the stream running (RecordingRouter.set_paced_from_rate); the worker no longer pre-decides from the stopped-state probe. Tests: test_router_decides_the_regime_from_the_running_stream_rate, test_recording_switches_to_paced_routing_when_the_camera_only_paces_once_streaming.
| self._thread.join(timeout=max(0.1, deadline - time.monotonic())) | ||
| if self._thread.is_alive(): | ||
| # Slow but progressing backlog — NOT wedged (flagging it would | ||
| # fail-fast whole multi-well runs over stores that finish fine). | ||
| # The daemon drain seals the store when it finishes. | ||
| _log.warning("drain thread still writing after finalize() join timeout; store seals when it finishes") |
There was a problem hiding this comment.
[Claude Code] Fixed in 6c36b1a - finalize() returns whether the store is sealed and RecordingWriter.wait_sealed() waits for a drain that outlived it. RecordZStackWorker tracks those writers, fails the run on a write error that surfaces afterwards (checked at the next plane and before .done), and withholds .done while a store is still being written. Tests: test_finalize_reports_a_drain_that_outlived_it_and_wait_sealed_sees_its_errors, test_late_recording_write_error_ends_the_run_as_error, test_run_withholds_done_while_a_recording_is_still_being_written.
| worker = RecordZStackWorker( | ||
| scope=self._microscope, | ||
| live_controller=self._live_controller, | ||
| laser_auto_focus_controller=self._laser_af, | ||
| objective_store=self._objective_store, | ||
| params=params, | ||
| callbacks=self._callbacks, | ||
| abort_requested_fn=lambda: self._abort_event.is_set(), | ||
| request_abort_fn=self.request_abort, | ||
| scan_region_fov_coords=scan_region_fov_coords, | ||
| display_frame_fn=self._display_frame_fn, | ||
| display_fps=self._display_fps, | ||
| ) | ||
|
|
||
| self._thread = Thread(target=worker.run, name="RecordZStack-acquisition", daemon=True) |
There was a problem hiding this comment.
[Claude Code] Fixed in 6c36b1a - RecordZStackController starts the run.json breadcrumb through a new start_run_state_writer helper (MultiPointController now uses the same one); the worker beats per FOV and ends it with the same reasons as MultiPointWorker (_compute_end_reason and the run-state writer moved to MultiPointWorkerBase). Test: test_record_zstack_run_writes_the_watchdog_breadcrumb.
| self._move_z_to_offset(z_ref, offsets[0]) | ||
| self.liveController.turn_on_illumination() |
There was a problem hiding this comment.
[Claude Code] Fixed in 6c36b1a - the first Z move and the illumination enable run inside record()'s try, so the finally restores Z and the camera mode when either raises. Test: test_record_restores_z_and_camera_mode_when_illumination_fails.
| self.camera.start_streaming() | ||
| cb_id = self.camera.add_frame_callback(self._image_callback) |
There was a problem hiding this comment.
[Claude Code] Fixed in 6c36b1a - start_streaming() and add_frame_callback() run inside zstack()'s try with cb_id initialized to None; the finally removes the callback only when it was registered and always stops streaming. Test: test_zstack_stops_streaming_when_callback_registration_fails.
- Recording pacing is re-decided from the rate the camera reports with the stream running (RecordingRouter.set_paced_from_rate, fed by ContinuousFrameSource): ToupTek rejects the stopped-state rate write after a binning change and reports its free-run maximum, which left an at-rate sensor on nearest-slot routing and sealed its recordings incomplete. - A recording whose drain outlives finalize() stays tracked: finalize() returns whether the store is sealed, RecordingWriter.wait_sealed() waits for it, and the worker fails the run on a write error that surfaces afterwards and withholds .done until every store is sealed. - Record + Z-Stack writes the acquisition-watchdog run.json: both controllers start it through start_run_state_writer, the worker beats per FOV and ends it with the same reasons as MultiPointWorker (_compute_end_reason and the run-state writer live on MultiPointWorkerBase); the worker's crash path tags the error cause. - record() and zstack() start their cleanup regions before the first hardware step, so a failed illumination enable or callback registration still restores Z, the camera mode and the stream state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The recording's pacing regime is decided once, by the capture, from the rate the camera reports with the stream running; the worker's stopped-state probe now only resolves the effective fps. - The watchdog beat, the run-ended breadcrumb and the camera-streaming lifecycle are MultiPointWorkerBase methods shared by both workers, and the crash path of both goes through _abort_due_to_error(), which replaces the _run_state_fatal flag. - start_run_state_writer builds the breadcrumb's size block from counts. - Tests: one gated-future helper and ZarrWriter I/O stub in test_stubs, one parameter builder and controller harness for the record-zstack tests, and the watchdog state-dir fixture now covers every test (the record-zstack controller tests under tests/core were writing run.json into the real user state dir). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eps the hardware trigger source The SDK moves the trigger source to software along with OPTION_TRIGGER only while the stream runs. Record + Z-Stack stops the stream between phases, so a run started from hardware-trigger live view switched to software trigger with the source still on GPIO0: the first z-stack trigger was ignored and the run aborted with no z-stack (3/3 on an ITR3CMOS26000KMA). Set the source explicitly, as the hardware branch does for its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hardware test results (head 54eb11c)Run on a Squid+ (ToupTek ITR3CMOS26000KMA, 20x, 2×2 binning, Teensy controller, Daheng laser AF camera) on 2026-10-12, with a 96-well glass-bottom plate of fluorescent beads: G10 has moving beads, G11 stuck beads. The tests are the ones in @hongquanli's test plan and @Alpaca233's PR description, driven through the Record + Z-Stack tab. Result: 10 of 11 pass on 54eb11c. Trigger restore fails every time (3/3). With the one-line fix below, all 11 pass, including trigger restore 2/2.
Hardware-trigger live: the failure and its causeSetting live view to Hardware Trigger, starting and stopping live, then running both phases: the recording completes (30/30 frames), then the z-stack's first software trigger never returns a frame. The worker logs Cause. The ToupTek trigger source (
The recording itself is not involved, and hardware trigger keeps working after a recording (25 frames in 3 s of hardware-trigger live). Fix: d5df796 on this branch. The run now makes its one switch to software trigger right after stopping live view, with the camera streaming (it starts the stream first if a previous run left it stopped). That is the case where the camera moves the source itself, as in the first two rows. A unit test ( Verified with the fix on this microscope:
Not covered by these tests
Notes
🤖 Generated with Claude Code |
|
I could not find any bugs in output. |
…n, with the camera streaming
A run started from Hardware Trigger lost its z-stack on a ToupTek camera: the first z-stack
trigger never returned a frame ("Timed out waiting ... for a frame") and no z-stack was written.
The camera moves its trigger source off the hardware input only when the trigger mode changes on
a running stream, and every mode write in a run happened on a stopped stream (each phase stops
the camera on the way out). The run now makes its one switch to software trigger right after
stopping live view, starting the stream first if needed; zstack()'s own switch stays as the
fallback, and the user's mode is still restored at the end.
On an ITR3CMOS26000KMA: the Hardware-trigger live test passes 2/2 (failed 3/3), two hardware-
trigger runs back to back pass (the second starts with the camera stopped), and the other 10
hardware tests still pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…un-start software-trigger switch Comment-only follow-up to d5df796: which runs it touches (z-stack runs that start outside software trigger), the one new side effect (a stopped camera is started), that the camera behaviour it works around was measured on one ToupTek model and is not documented, and that the zstack() fallback keeps the LiveController in sync but does not cure the trigger source on a ToupTek camera. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or started The channel tables' exposure editor set a 0.01 ms minimum and one decimal; Qt rounds that minimum to 0.0, so 0 ms could be entered. The camera rejects a 0 ms exposure when the run reaches that channel (ToupTek: "The parameter is incorrect"), which aborted the acquisition with the z-stack half written and no hint of the cause. Seen on an ITR3CMOS26000KMA with a 405 nm z-stack row at 0 ms: 2 of 27 images, store sealed incomplete. - The editor's minimum is 0.1 ms, the smallest value one decimal shows, in both the recording row and the z-stack rows. - validate_record_zstack_params refuses a recording or z-stack exposure <= 0 and names the channel, so a zero that arrives another way (a settings file) is stopped at Start. Scope: only the Record + Z-Stack tab's channel tables and its Start validation. On the microscope: typing 0 gives 0.1 ms and a 488 + 405 z-stack at that minimum completes; a 0 ms row is refused at Start with no experiment folder created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Summary
Adds a new Record + Z-Stack acquisition mode (its own tab, next to Wellplate Multipoint, gated by
ENABLE_RECORDING). It walks selected wells × a per-well FOV grid ×Nttime points and, at each FOV, runs two phases in sequence:T = round(fps × duration)frames at one Z plane, saved as a per-FOV Zarr(T, 1, 1, Y, X).(Nt, C, NZ, Y, X)per FOV.Both phases are positioned in Z by offsets relative to a reference plane (laser reflection AF when enabled+referenced, otherwise the current Z). Each phase is independently enable-able.
Stacked on #691
This PR is based on
feat/in-process-zarr-saving(#691) and shows only its own commits; merge #691 first, then retarget this one tomaster.Why: every recording uses tensorstore in the main process (
RecordingWriter), and tensorstore aborts anyfork()ed child of a process that has used it. The z-stack used to be saved by a forkedJobRunnersubprocess that was forked at the start of the previous acquisition, so from the third Record + Z-Stack acquisition of a session on, the subprocess died on its first write and the z-stack was silently lost (reproduced in simulation: runs 1–2 complete, 3–4 lost; the recording itself was intact). #691 saves Zarr v3 in the acquisition process; on top of it this PR's last commit makes the z-stack use thatInProcessZarrRunner, drops the record controller's pre-warm slot, polls save results after every FOV (a failed write ends the run as an error with the store sealedaborted), and removes theUSE_MULTIPROCESSING=Falsetest workarounds that only existed to dodge the abort.test_three_record_zstack_acquisitions_all_sealedpins the scenario; the full suite now runs in one process without the old stall at the worker smoke test.Architecture
MultiPointWorkerBase— behavior-preserving refactor lifting the shared capture mechanics (channel apply, single-frame capture, frame callback +SaveZarrJobdispatch, backpressure, abort/progress) out ofMultiPointWorker. The acquisition-watchdog hooks (_abort_due_to_error,_compute_end_reason, the run-state writer) live on the base; both controllers start therun.jsonbreadcrumb through onestart_run_state_writerhelper and both workers end it with the same reasons, so Record + Z-Stack runs are visible to the watchdog like multipoint runs.StreamingCapture(control/core/streaming_capture.py) — the recording primitive: a continuous frame source → bounded queue (count- and byte-capped) → writer thread → directZarrWriter. Genericframe_source/frame_router/stop_conditionseam, shaped so a future hardware-sequenced source drops in.RecordZStackWorker/RecordZStackController— thin t×well×FOV loop reusing the inherited mechanics; recording viaStreamingCapture, z-stack viaSaveZarrJobon theInProcessZarrRunnerfrom feat(acquisition): save Zarr v3 in the acquisition process #691 (no save subprocess). Timepoints are paced on the absolutet0 + k·dtgrid with grid-preserving skip (mirrorsMultiPointWorker); each experiment dir gets theacquisition_channels.yamlsnapshot and, once every recording store is sealed, the.donemarker like every multipoint acquisition.RecordZStackMultiPointWidget+gui_hcstab wiring. Per-well FOV grid configurable inline and rebuilt on tab entry/well clicks; channels editable inline with Copy-from-Live; channel combos refresh on objective/profile/config changes. XY/Time controls mirrorWellplateMultiPointWidget's tabbed row (see below).AbstractCamera.set_frame_rate(fps)(no-op default; ToupTekPRECISE_FRAMERATE; simulated camera honors it). The recording sizes its dataset, pacing, andtime_increment_sfrom the achievable rate the camera reports, with software downsampling as the portable safety net.Widget UI polish
WellplateMultiPointWidget(checkbox + combo in a frame that highlights orange/green when active), skipping Z since the widget's own Z-Stack phase group already covers z-stacking:combobox_xy_modenow offers two real modes: Select Wells (today's tile-a-grid-over-selected-wells behavior, default) and Current Position (single FOV at the live stage position, bypassing well selection entirely —validate()no longer requires a well selection in this mode). Unchecking XY forces Current Position and disables the combo; re-checking restores the previous mode.checkbox_timewraps Nt/dt: unchecked forces a single timepoint and hides the controls, restoring the previous Nt/dt on re-check.checkbox_laser_afmoved into this row as a plain checkbox, matching howWellplateMultiPointWidgetplaces its own Laser AF toggle.liveController.get_channels(), instead of a hardcoded 50 ms / 0 gain / 50%.centralWidget.setFixedWidth(minimumSizeHint())ingui_hcs.pysizes the whole app once at startup from whichever tab is active then) — in favor of a responsive fill plus tightened field widths, so the panel no longer needs a horizontal scrollbar. The Z-Stack channel table now spans the group's full width to match the Recording table, and z-stack channel names get a tooltip with the full name since the Channel column truncates.refresh_channel_list()was silently resetting the user's manually-edited recording exposure/gain/illumination back to the channel's base config (repopulating the combo transiently firescurrentIndexChanged); a checkbox toggle could rebuild the scan region twice; dark-theme text contrast on the new Time tab; duplicated channel-lookup logic consolidated into a shared_find_channel()helper.Data-integrity guarantees (recording pipeline)
These came out of the review rounds below and are worth stating as contract:
acquisition_complete: trueis only stamped when every expected frame was written. Write errors, backpressure drops, and under-delivery (camera stall) all seal the store incomplete with counts (write_errors,dropped_frames,captured_frames/expected_frames); user aborts additionally stampaborted: true.get_resolution()— sensor-vs-delivered mismatches previously produced silently blank recordings on real cameras.finalize()stays tracked: a write error that surfaces afterwards still fails the run, and.doneis withheld until the store is sealed.Review & hardening
Three review rounds (multi-agent adversarial review with independent verification, plus hand-verification when API limits interrupted round 2) — 40 confirmed findings, all fixed, each with a test that failed first:
get_resolution()sizing, error-swallowing drain, finalize deadlock, unresponsive abort, trigger/channel state corruption after runs, fps-gate jitter halving capture rate, count-only queue bound (~13 GB RAM risk), per-FOV z coords dropped, dt pacing/metadata mismatch, missing cleanup/bookkeeping.finalize()returned, norun.jsonfor Record + Z-Stack, and two cleanup regions that started after the first hardware step.Testing
StreamingCapture/RecordingWriter/RecordingRouter(error/OOB/abort/wedge/drop/jitter/burst paths), worker simulation (shapes, state restore, pacing, fail-fast, bookkeeping), widget (validation, Copy-from-Live, refresh, Start/Stop handoff, FOV-grid wiring), gui-level tab/selector behavior, camera fps.Nt=2t-indexing, phase-solo runs, z-offset stage return, back-to-back acquisitions.install-and-testsuite) after resolving the master conflict — note the PR had beenCONFLICTING, which silently skipspull_requestworkflows, so earlier pushes only ran lint.blackclean (CI 25.12.0).Hardware testing (pending — the remaining merge gate)
See the test-plan comment: instrument checklist + a camera-only bench mode (real ToupTek camera, everything else simulated via the
[SIMULATION]INI section). Highest-value bench test: thePRECISE_FRAMERATEfps hint, which has never executed against real hardware. Also record once right after changing the binning: the log should showrouting switched to camera-pacedand the store must sealacquisition_complete: true.Added by the stacking: on the instrument, run 4 Record + Z-Stack acquisitions back to back in one session (ToupTek, whose callback thread persists across runs); every z-stack
zarr.jsonmust haveacquisition_complete: trueand the terminal must show neitheraborting: fork()norEvent loop is closed.Deferred follow-ups (documented, out of scope here)
display_progress_bar,emit_selected_channels) with a protocol.🤖 Generated with Claude Code