Skip to content

feat: Record + Z-Stack acquisition mode - #564

Open
hongquanli wants to merge 115 commits into
feat/in-process-zarr-savingfrom
feat/record-zstack-acquisition
Open

hongquanli wants to merge 115 commits into
feat/in-process-zarr-savingfrom
feat/record-zstack-acquisition

Conversation

@hongquanli

@hongquanli hongquanli commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

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 × Nt time points and, at each FOV, runs two phases in sequence:

  1. Recording — a continuous single-channel video: T = round(fps × duration) frames at one Z plane, saved as a per-FOV Zarr (T, 1, 1, Y, X).
  2. Z-stack — a multi-channel stack over a Z range, saved as OME-Zarr (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 to master.

Why: every recording uses tensorstore in the main process (RecordingWriter), and tensorstore aborts any fork()ed child of a process that has used it. The z-stack used to be saved by a forked JobRunner subprocess 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 that InProcessZarrRunner, 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 sealed aborted), and removes the USE_MULTIPROCESSING=False test workarounds that only existed to dodge the abort. test_three_record_zstack_acquisitions_all_sealed pins 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 + SaveZarrJob dispatch, backpressure, abort/progress) out of MultiPointWorker. The acquisition-watchdog hooks (_abort_due_to_error, _compute_end_reason, the run-state writer) live on the base; both controllers start the run.json breadcrumb through one start_run_state_writer helper 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 → direct ZarrWriter. Generic frame_source/frame_router/stop_condition seam, shaped so a future hardware-sequenced source drops in.
  • RecordZStackWorker / RecordZStackController — thin t×well×FOV loop reusing the inherited mechanics; recording via StreamingCapture, z-stack via SaveZarrJob on the InProcessZarrRunner from feat(acquisition): save Zarr v3 in the acquisition process #691 (no save subprocess). Timepoints are paced on the absolute t0 + k·dt grid with grid-preserving skip (mirrors MultiPointWorker); each experiment dir gets the acquisition_channels.yaml snapshot and, once every recording store is sealed, the .done marker like every multipoint acquisition.
  • RecordZStackMultiPointWidget + gui_hcs tab 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 mirror WellplateMultiPointWidget's tabbed row (see below).
  • Camera frame-rate hint — AbstractCamera.set_frame_rate(fps) (no-op default; ToupTek PRECISE_FRAMERATE; simulated camera honors it). The recording sizes its dataset, pacing, and time_increment_s from the achievable rate the camera reports, with software downsampling as the portable safety net.

Widget UI polish

  • XY/Time tabbed row, mirroring 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_mode now 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_time wraps Nt/dt: unchecked forces a single timepoint and hides the controls, restoring the previous Nt/dt on re-check.
    • checkbox_laser_af moved into this row as a plain checkbox, matching how WellplateMultiPointWidget places its own Laser AF toggle.
  • Channel-add seeds from configured settings: the z-stack "+ Add" button and the recording-channel combo (initial row and on selection change) now seed exposure/gain/illumination from the channel's own config via liveController.get_channels(), instead of a hardcoded 50 ms / 0 gain / 50%.
  • Layout: removed the channel tables' fixed 530px width — it fought against whatever width the main window happened to lock in for this tab (centralWidget.setFixedWidth(minimumSizeHint()) in gui_hcs.py sizes 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.
  • ~20 new tests; caught in review and fixed with a reproducing test first: 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 fires currentIndexChanged); 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: true is 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 stamp aborted: true.
  • The recording dataset is sized from one real processed frame (crop/rotation/ROI applied), not get_resolution() — sensor-vs-delivered mismatches previously produced silently blank recordings on real cameras.
  • Frames map to the time slot nearest their arrival time: delivery jitter can't halve the capture rate, and a post-stall burst leaves the stall as fill holes instead of compressing the time axis. A camera that paces itself gets sequential slots instead; that decision is re-made from the rate the camera reports with the stream running (ToupTek rejects the stopped-state rate write after a binning change and reports its free-run maximum).
  • The acquisition fails fast (aborts, store sealed incomplete) on systematic recording failure: write errors, sustained drops, or a wedged writer — instead of grinding through hours of blank FOVs. A wedged writer's captured backlog is flushed once the stall clears, never discarded. A recording whose drain thread outlives finalize() stays tracked: a write error that surfaces afterwards still fails the run, and .done is withheld until the store is sealed.
  • Hardware state is restored after every run: trigger mode, channel configuration (exposure/gain/illumination), live view, and Z.

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:

  • Round 1 (17): critical hardware-path bugs simulation couldn't surface — blank recordings from 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.
  • Round 2 (10 + 13 after full verification): completeness-attribute lies (drops/under-delivery), wedged-finalize data discard and false-positive fail-fast, well-selector lost after acquisitions, inert well clicks on the tab, silent recording-channel swaps, monotonic pacing, seal-attribute honesty, snapshot dedupe, per-FOV double camera reconfiguration.
  • Merge with master ported the acquisition-watchdog (feat: acquisition watchdog — Slack alert on prematurely-ended acquisitions #565) and z-offset logging (feat(worker): log actual z after each per-channel z-offset move #573) into the refactored base class.
  • Copilot round on the stacked branch (5, all fixed with a failing test first): pacing decided from a stopped-state rate write, late write errors lost after finalize() returned, no run.json for Record + Z-Stack, and two cleanup regions that started after the first hardware step.

Testing

  • ~120 new tests: 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.
  • Runtime-verified end-to-end by driving the real GUI in simulation (screenshots + on-disk Zarr shape/attr verification): happy path, validation rejects, mid-recording abort, Nt=2 t-indexing, phase-solo runs, z-offset stage return, back-to-back acquisitions.
  • CI green (full install-and-test suite) after resolving the master conflict — note the PR had been CONFLICTING, which silently skips pull_request workflows, so earlier pushes only ran lint.
  • black clean (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: the PRECISE_FRAMERATE fps hint, which has never executed against real hardware. Also record once right after changing the binning: the log should show routing switched to camera-paced and the store must seal acquisition_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.json must have acquisition_complete: true and the terminal must show neither aborting: fork() nor Event loop is closed.

Deferred follow-ups (documented, out of scope here)

  • GUI-visible error dialog when the fail-fast aborts an acquisition (currently loud in the log only; needs an error signal through the Qt controller).
  • closeEvent acquisition guard: app exit during a run now waits 30 s for the worker to unwind, but a still-running worker can race teardown beyond that; a proper "acquisition in progress — abort and exit?" prompt is the full fix.
  • NTP-step resilience for the recording router's absolute-slot anchor (wall-clock frame timestamps; a mid-recording clock step mispaces until re-anchored).
  • From the original plan: replacing the duck-typed record-tab stubs (display_progress_bar, emit_selected_channels) with a protocol.

🤖 Generated with Claude Code

@hongquanli

hongquanli commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Hardware test plan

Simulation testing (GUI driven end-to-end, Zarr outputs verified — happy path, validation, Copy-from-Live, mid-recording abort, Nt=2 t-indexing, recording-only / z-stack-only, z-offset with stage return) is green as of 9b5be65. The items below are the paths that only run on real hardware and should be checked on an instrument before this ships.

Setup

  1. Add enable_recording = True to the [GENERAL] section of configuration_*.ini and restart.
  2. The Record + Z-Stack tab appears next to Simple Recording. Switching to it should keep the well selector visible and log no traceback (fixed in 9b5be65 — worth re-confirming on the instrument build).
  3. Put a real sample (or fluorescent slide) on stage, focus, and select 1–2 wells.

Tests

  • 1. Recording illumination (validates the critical fix). Recording phase only, BF channel, 10 fps × 5 s. Open the recording Zarr — frames must show the sample, not darkness. Repeat with a fluorescence channel. This exercises the illumination-energize fix that simulation can't prove (the sim camera synthesizes images regardless of illumination).
  • 2. fps hint (ToupTek PRECISE_FRAMERATE). 10 fps × 10 s → expect exactly T=100 frames, _squid.time_increment_s = 0.1, wall time ≈ 10 s. Then request an impossible rate (e.g. 50 ms exposure @ 30 fps) — the portable safety net should downsample in software; check the log for dropped/queue-full warnings and confirm the Zarr still seals with acquisition_complete: true.
  • 3. Hardware-trigger live interplay. Set live to Hardware Trigger, start live, stop live, then run an acquisition with both phases. The z-stack must not come back dark (the trigger-mode fix), and after the acquisition live view must restore and work in the previously selected mode.
  • 4. Laser AF reference path. Initialize laser AF and set a reference, tick Laser AF in the tab, run with a recording z-offset (e.g. +10 µm) and a z-stack range. Both phases should be positioned relative to the reference plane — compare focus between the recording frames and the z-stack center plane. Also confirm Start is rejected with a clear message when Laser AF is ticked without a reference.
  • 5. Abort mid-recording. 60 s recording, hit Stop after ~10 s. UI must unlock immediately, the recording Zarr must carry _squid.aborted: true / acquisition_complete: false, stage and illumination must return to a sane state, and a follow-up acquisition must work without restarting the app.
  • 6. Sustained throughput / backpressure. Realistic load: full-resolution camera, ~30 fps × 60 s recording × 2 wells × a small FOV grid, plus a multi-channel z-stack. Watch the RAM indicator, check the log for queue full / drop warnings from the recording writer, and verify every per-FOV Zarr has the full expected frame count.
  • 7. Multi-timepoint on hardware. Nt=3, dt ≥ per-timepoint duration. Expect recording/t0..t2/ trees and a z-stack Zarr (3, C, NZ, Y, X) with all three t-slices populated (verified in simulation; re-check with real stage settle/AF timing).

Inspecting outputs

Per-FOV shapes: recording (T, 1, 1, Y, X), z-stack (Nt, C, NZ, Y, X). Quick check without opening napari:

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: squid.control.core.zarr_writer warnings (abort path is intentionally loud), recording-writer drop warnings, and [MCU] >>> illumination commands around phase transitions.


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 configuration_*.ini:

[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 = true

Then launch without --simulation (the flag forces everything simulated; without it the per-component settings apply — see _should_simulate in microscope.py). Stage moves become instant no-ops; every frame comes off the real sensor.

What this bench covers, in value order:

  • fps hint (PRECISE_FRAMERATE) — the new camera_toupcam.py code has never executed against real hardware (the simulated camera has its own set_frame_rate). 10 fps × 10 s → exactly T=100 frames, wall time ≈ 10 s, _squid.time_increment_s = 0.1.
  • StreamingCapture under real frame timing — real callback thread, real jitter, full-res frames into the bounded queue. Feasible rate: no drop warnings, exact count. Then request an infeasible rate (e.g. 100 ms exposure @ 30 fps) and observe: does the run stretch until T frames arrive, or do drops get logged loudly?
  • Real pixels through the save path — point the camera at the room, wave a hand during recording, verify frame-to-frame differences in the Zarr are non-zero (a live temporal stream, not repeated buffers).
  • Abort/recovery with a real device — Stop mid-recording → aborted: true seal → Start Live again streams normally without replug/restart.
  • Multi-well/FOV fan-out at real data rates — 2 wells × 4 FOVs; simulated stage walks instantly, per-FOV Zarr routing carries real frames.

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

hongquanli added a commit that referenced this pull request Jul 5, 2026
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>
hongquanli added a commit that referenced this pull request Jul 5, 2026
…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>
@Alpaca233
Alpaca233 requested a review from Copilot July 6, 2026 03:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/RecordZStackWorker plus GUI wiring for a new “Record + Z-Stack” tab.
  • Refactors shared capture/job-dispatch mechanics into MultiPointWorkerBase and 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.

Comment on lines +308 to +312
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:
Comment on lines +249 to +253
deadline = time.monotonic() + timeout_s
while True:
try:
self._q.put(_SENTINEL, timeout=min(1.0, max(0.1, timeout_s)))
break
hongquanli added a commit that referenced this pull request Jul 27, 2026
feat: multi-plane recording — N z-planes per FOV (stacks on #564)
@Alpaca233
Alpaca233 requested a review from Copilot July 27, 2026 16:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Comment on lines +359 to 363
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in lastest master

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.")
Alpaca233 pushed a commit that referenced this pull request Jul 27, 2026
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>
@Alpaca233
Alpaca233 force-pushed the feat/record-zstack-acquisition branch from 0afae24 to d07b875 Compare July 27, 2026 21:31
@Alpaca233
Alpaca233 requested a balanced review from Copilot September 28, 2026 20:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +254 to +255
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()
Comment on lines +17777 to +17779

# ---------------------------------------------------------------------- public API

Alpaca233 pushed a commit that referenced this pull request Sep 29, 2026
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>
@Alpaca233
Alpaca233 force-pushed the feat/record-zstack-acquisition branch from 59de99a to 1d2e72e Compare September 29, 2026 07:37
hongquanli and others added 6 commits October 11, 2026 11:34
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>
hongquanli and others added 13 commits October 11, 2026 11:40
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>
@Alpaca233
Alpaca233 force-pushed the feat/record-zstack-acquisition branch from 1d2e72e to 3442c90 Compare October 11, 2026 19:01
@Alpaca233
Alpaca233 changed the base branch from master to feat/in-process-zarr-saving October 11, 2026 19:01
Alpaca233 and others added 2 commits October 11, 2026 14:11
_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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Comment on lines +399 to +403
# 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +394 to +399
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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +370 to +384
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +337 to +338
self._move_z_to_offset(z_ref, offsets[0])
self.liveController.turn_on_illumination()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +525 to +526
self.camera.start_streaming()
cb_id = self.camera.add_frame_callback(self._image_callback)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Alpaca233 and others added 3 commits October 11, 2026 15:16
- 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>
@maragall

maragall commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Test By Result Measured
Tab and setup hongquanli ✅ The tab is present, the well selector stays visible, and no traceback appears.
Recording illumination hongquanli ✅ BF frames average 2,261 and 488 frames 4,723, against about 870 with the light off. The sample is visible in both.
fps hint hongquanli ✅ 10 fps × 10 s gives T = 100, time_increment_s 0.1, and 10.02 s from stream start to finish. 30 fps at 50 ms exposure warns before Start and logs camera cannot deliver 30 fps at 50 ms exposure (max 20.00). That run recorded at 20 fps and sealed acquisition_complete: true.
Real pixels hongquanli ✅ Moving beads, 100 frames, no identical consecutive pair.
Hardware-trigger live hongquanli ❌ → ✅ with fix See below.
Laser AF reference hongquanli ✅ Start is refused without a reference: "Laser AF is enabled but no reference position has been captured." With a reference and a +10 µm offset, the sharpest z-stack plane is at 0 µm, the recording matches the +10 µm plane, and Z returns to the reference afterwards.
Abort mid-recording hongquanli ✅ Stop at 10 s of 60 s. The UI unlocks in 0.25 s and the store is sealed aborted: true, acquisition_complete: false. Illumination is off, Z is back at the reference, and the next run completes without a restart.
Throughput and fan-out hongquanli ✅ 30 fps requested, 28 fps achieved (the camera's maximum at 20 ms exposure), × 60 s × 2 wells × 2 FOVs, plus a BF + 488 z-stack. All 4 recordings have T = 1680 and are complete, all 4 z-stacks are complete, and no drop or stall appears in the log. Process RSS stays between 0.61 and 0.73 GB.
Multi-timepoint hongquanli ✅ Nt = 3 gives recordings t0..t2 in both wells. The z-stacks are (3, 2, 7, 2084, 2084), all 3 t-slices are filled, and every store is complete.
After a binning change Alpaca233 ✅ Recorded right after switching to 1×1. The log shows recording routed camera-paced, sequential slots (camera reports 6.80 fps ...) and the store is complete. The log line quoted in the description (routing switched to camera-paced) does not exist at this head, so I checked against this one.
Back to back Alpaca233 ✅ 4 Record + Z-Stack runs in one session. Every z-stack and recording is complete, and neither aborting: fork() nor Event loop is closed appears.

Hardware-trigger live: the failure and its cause

Setting 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 Timed out waiting {non_hw_frame_timeout} [s] for a frame, aborting acquisition. and no z-stack store is written. This happened 3 of 3 times. Starting from Software Trigger, every z-stack works.

Cause. The ToupTek trigger source (IOCONTROLTYPE_TRIGGERSOURCE: 1 = GPIO0, 5 = software) follows OPTION_TRIGGER only while the stream runs. zstack() calls set_trigger_mode(SOFTWARE) on a stopped stream, so the option becomes 1 (software) while the source stays on GPIO0, and the camera ignores software triggers. Camera-level experiments, without Record + Z-Stack:

Sequence Source after the switch Software trigger
Hardware → Software, stream running 5 frame in 0.08 s
Hardware-trigger live, then Software, stream running 5 frame in 0.08 s
Hardware → Software, stream stopped 1 no frame
Same, with a continuous recording in between 1 no frame
Previous state, source set to 5 by hand 5 frame in 0.08 s

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. zstack()'s own switch stays as the fallback, and the user's mode is still restored at the end. The shared ToupTek driver is unchanged. A driver-level version (#704, now closed) was dropped to keep the change inside this feature.

A unit test (test_hardware_trigger_run_switches_to_software_while_streaming_before_recording) fails without the change and passes with it. All 52 worker tests pass.

Verified with the fix on this microscope:

  • Hardware-trigger live passes 2/2. The z-stack is lit (BF about 10,660 and 488 about 2,890, against about 870 dark), it seals complete, and live view comes back in Hardware Trigger.
  • Two hardware-trigger runs back to back pass. The second run starts with the camera stopped, and both z-stacks are lit and complete.
  • The other 10 tests still pass.

Not covered by these tests

  • Level-mode hardware triggering (HARDWARE_TRIGGER_MODE = LEVEL). This microscope uses edge mode.
  • Other ToupTek models.
  • Hardware-path changes that no test in the plan reaches:
  • Per-FOV stored Z was used implicitly, because every FOV here carried its own Z, but it was not checked as a test of its own.

Notes

  • The timeout message above is missing its f prefix (multi_point_worker.py:701), so it prints {non_hw_frame_timeout} literally.
  • With laser AF on, a FOV whose stored Z was 18.5 µm away from the current focus (the plate had drifted over an hour) landed 7.9 µm above the reference. With the FOV's Z at the current focus, laser AF landed exactly on the reference. This looks like the laser AF's working range, not this PR.
  • Wellplate BF images in Hardware Trigger read about 7,680, against about 10,750 in Software Trigger at the same settings, with or without the fix. I have not looked into this.

🤖 Generated with Claude Code

@maragall

Copy link
Copy Markdown
Collaborator

I could not find any bugs in output.

maragall and others added 4 commits October 12, 2026 05:30
…tream keeps the hardware trigger source"

This reverts commit eb441dd. The fix moves to its own PR, #704,
which is stacked on this branch and merges into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants