Skip to content

feat: Record + Z-Stack acquisition mode#564

Open
hongquanli wants to merge 70 commits into
masterfrom
feat/record-zstack-acquisition
Open

feat: Record + Z-Stack acquisition mode#564
hongquanli wants to merge 70 commits into
masterfrom
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.

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. After merging master, the acquisition-watchdog hooks (_abort_due_to_error, per-image run-state beats) live on the base so both workers classify error-aborts identically.
  • 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 the existing SaveZarrJob path. 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 .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 hintAbstractCamera.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.
  • 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.
  • 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.

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.

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: shared pre-warmed-JobRunner mixin, and replacing the duck-typed record-tab stubs (display_progress_bar, emit_selected_channels) with a protocol.

🤖 Generated with Claude Code

hongquanli and others added 30 commits June 21, 2026 01:55
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 Sonnet 4.6 <noreply@anthropic.com>
…rt/write race)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Appends ContinuousFrameSource (wraps AbstractCamera into start/stop source)
and StreamingCapture (orchestrates source, router, stop-condition, writer)
to streaming_capture.py.  run() accepts an optional timeout parameter so
Task D can bound wall-clock wait without hanging on a stalled camera.
Adds fake-source test confirming 5-frame emit + downsampling + finalize.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le); document stop/finalize assumption

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add RecordZStackAcquisitionParameters dataclass and pure helper functions
frame_count, zstack_plane_count, and zstack_offsets_um for z-stack and
recording acquisition planning. These helpers handle frame counting,
z-plane calculation with epsilon tolerance, and offset list generation.
Includes full test coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address code review: add ValueError tests for invalid z-stack inputs
(z_max<z_min, step<=0) and add a comment explaining the floor epsilon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add RecordZStackWorker(MultiPointWorkerBase) orchestrating per-FOV
record + z-stack acquisition:
- record() streams a continuous high-fps capture to a per-FOV recording
  .ome.zarr via the C3 StreamingCapture primitive
- zstack() reuses the inherited triggered-capture + SaveZarrJob dispatch
  path (builds its own JobRunner + BackpressureController like
  MultiPointWorker), managing camera streaming/callback lifecycle locally
- establish_reference() uses laser AF when available, falls back to
  current stage Z
- run() loops time points -> regions -> FOVs, abort- and dt-aware

Smoke test (simulated microscope, 2 wells x 2 FOV x 2 t, both phases)
verifies recording dataset count/shape and z-stack per-FOV zarr shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d fix

- Add RecordZStackController to record_zstack_controller.py: builds params
  via setters, pre-warms a JobRunner subprocess (mirrors MultiPointController),
  resolves a timestamped experiment dir via create_experiment_dir, constructs
  RecordZStackWorker, and spawns it on a daemon thread.  Exposes request_abort(),
  join(), close(), and per-param setters for the widget.
- Fix ZarrWriter._get_loop(): after calling get_event_loop() check that the
  returned loop is not already closed; if it is, fall through to new_event_loop()
  so recordings on daemon threads across multiple time-points don't raise
  'Event loop is closed'.
- Add test_record_zstack_controller_smoke: controller-driven test covering
  Nt=2 with dt_s=0.1, both recording and z-stack phases, shape assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements Task E1: widget skeleton (Option-A single-column layout with
Output, Wells & FOV, Time-lapse/Focus, Recording phase, Z-Stack phase,
Start groups), pure _validate_record_zstack_params() helper, validate(),
build_parameters(), and _add_zstack_channel_row().

18 tests pass (13 pure-helper, 5 widget via qtbot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ow table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… labels

- Recording group: add exposure/gain/illumination spinboxes and a
  '⟳ Live' button that copies currentConfiguration into the recording
  channel dropdown + inline editors
- Z-stack table expanded to 5 columns (channel, exposure, gain,
  illumination, actions); each row has per-channel spinboxes, a
  '⟳' copy-from-live button, and a '✕' remove button
- '+ Add channel' combo+button below the z-stack table
- Added _remove_zstack_channel_row(), _set_zstack_row_values(),
  _get_zstack_row_values(), _copy_recording_from_live(),
  _copy_zstack_row_from_live(), _on_zstack_add_channel_clicked()
- build_parameters() now reads inline editor values for both the
  recording channel and each z-stack row; uses model_copy(deep=True)
  to avoid mutating source channel objects
- 7 new qtbot tests added (26 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…RDING)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Completes E4: gui_hcs.py:1148 connects acquisition_finished ->
recordZStackWidget.acquisition_is_finished; these slots were authored
with E4 but omitted from its commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the recordZStackController sentinel; avoids a latent AttributeError
if recordZStackWidget is referenced when ENABLE_RECORDING is False.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… lifecycle

CRITICAL 1: recording captured dark frames — turn illumination on around
cap.run() (off in finally), since set_microscope_mode only energizes
illumination when live and the CONTINUOUS stream does not gate it.

CRITICAL 2: z-stack used the wrong acquire_camera_image branch — set
liveController.set_trigger_mode(SOFTWARE) (capturing/restoring the previous
mode) so camera mode and liveController.trigger_mode agree.

IMPORTANT 6: zstack() now calls camera.stop_streaming() in the finally,
mirroring ContinuousFrameSource.stop(), so the next FOV's recording starts
on a stopped camera.

IMPORTANT 9a: hoist stop-live to run() start (once, capturing was_live) and
restart live once in run()'s finally; removed per-FOV stop-live from record().

MEDIUM: precompute zstack offsets and frame shape once in __init__.
SIMPLIFICATION: resolve recording_channel to a local in record().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g, non-blocking enqueue, abort sealing, partial warning)

- ContinuousFrameSource.start: set CONTINUOUS mode before set_frame_rate so the
  toupcam PRECISE_FRAMERATE hint survives the mode switch (IMPORTANT 5).
- RecordingWriter: track _started, set only after thread.start(); finalize/abort
  skip the join when not started so a failing initialize() propagates its real
  error instead of a 'cannot join thread' crash (IMPORTANT 7).
- StreamingCapture._on_frame: re-check stop condition before routing so no frame
  is enqueued at/past t-index T into a (T,...)-shaped dataset (IMPORTANT 8).
- RecordingWriter.enqueue: put_nowait + drop-on-full (no 0.5s block on the camera
  hot thread); default queue maxsize 64 -> 256 (MEDIUM).
- StreamingCapture: track _aborted; run() finally calls writer.abort() on abort
  (seals zarr incomplete) else finalize() (MEDIUM).
- StreamingCapture.run: WARNING when emitted < expected (CountStop.expected());
  trailing zarr planes are blank fill (MEDIUM).
- Document that the post-stop _emitted read is safe (MINOR).
- Tests: start-error path, OOB gating, abort->writer.abort, complete->finalize,
  partial warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n fixes

- IMPORTANT 3+4: fix _get_selected_well_count to resolve lazily via
  scanCoordinates.get_selected_wells() instead of a stale cached
  well_selection_widget; handles None (glass-slide → count=1) and
  empty dict (no wells selected → count=0 → validation rejects)
- IMPORTANT 9b: reject recording configs where frame_count(fps,
  duration_s) < 1 in _validate_record_zstack_params with clear message
- MEDIUM: replace _abort_requested bool with threading.Event
  (_abort_event); set/clear/is_set are thread-safe; abort_requested_fn
  passed to worker uses lambda: self._abort_event.is_set()
- REUSE: add run_acquisition(params) direct-params path; toggle_acquisition
  now calls run_acquisition(self.build_parameters()) — no 15-setter fan-out;
  setters kept as shims for test_record_zstack_worker.py compatibility
- SIMPLIFICATION: remove unreachable None guard in
  _on_zstack_add_channel_clicked; log warnings in _get_zstack_row_values
  instead of silently substituting defaults
- Tests: 39 pass (8 new); cover frame_count=0, glass-slide/None well
  count, lazy well-selector resolution, and abort Event lifecycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… base, extract compute_pixel_size_um, drop redundant _job_runners re-assignments, fix misleading __class__ comment

- REUSE#1: move _wait_for_outstanding_callback_images verbatim to
  MultiPointWorkerBase; remove subclass overrides from MultiPointWorker
  and RecordZStackWorker (both inherit the shared implementation)
- REUSE#2: add compute_pixel_size_um(objective_store, camera) to
  acquisition_setup.py; call from both worker __init__s
- REUSE#5: deferred — ZarrWriterInfo constructions differ materially
  (is_hcs, use_6d_fov) between MultiPointWorker and RecordZStackWorker
- SIMP#1: drop redundant self._job_runners = [] in MultiPointWorker
  and self._job_runners/_backpressure/_abort_on_failed_job/
  _first_job_dispatched in RecordZStackWorker (base __init__ sets them)
- B1: correct misleading comment — __class__ in a method body always
  refers to the defining class, not the runtime type; type(self) is
  what produces per-subclass logger names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ix-batch5)

- Add signal_acquisition_started(bool) to RecordZStackMultiPointWidget, emitting
  True on valid start and False in acquisition_is_finished(), mirroring the pattern
  used by WellplateMultiPointWidget and FlexibleMultiPointWidget.
- Connect the signal to gui_hcs.toggleAcquisitionStart inside the ENABLE_RECORDING
  guard so other tabs, click-to-move, and live-scan-grid are locked during acquisition.
- Update toggle_acquisition docstring to reflect build_parameters() call path.
- Add comment in record_zstack_controller explaining the abort_event clear→start window.
- Add dropped_count property to RecordingWriter and a summary WARNING log in
  StreamingCapture.run so slow-disk dropped frames are diagnosable without grepping.
- Add 5 new tests covering the signal and dropped-count behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review found a race: toggle_acquisition emitted True AFTER run_acquisition()
returned, but run_acquisition spawns a worker thread that on a fast/one-frame
acquisition could finish (acquisition_is_finished -> emit False) before the GUI
thread reached the emit(True) line. The GUI would then process False then True,
permanently locking all tabs.

Fix mirrors WellplateMultiPointWidget (which emits True via
_set_ui_acquisition_running before run_acquisition):
- Emit True before calling run_acquisition.
- Wrap run_acquisition in try/except; on failure un-check the button and emit
  False to unlock the UI (the worker never started, so acquisition_finished
  will not fire).
- Document the emit(False) paths in acquisition_is_finished docstring.
- Add 2 tests: emit-ordering (True before run) and run-raises (True then False).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntroller

The widget already uses the clean run_acquisition(params) path, so the
15 one-line setter methods, the corresponding duplicate per-field instance
attributes, and the legacy params=None fallback are all dead code. Remove
them and update the controller test (test_record_zstack_controller_smoke)
to build RecordZStackAcquisitionParameters directly and pass it to
run_acquisition(params), matching how the widget works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hongquanli and others added 14 commits July 4, 2026 22:39
…et_type

Replace the hardcoded multipointController.camera lookup with an
overridable _get_camera_for_binning_check() hook, and change
_get_other_widget_name() to map an actual widget_type string to a
display name via a lookup table instead of guessing a binary
wellplate/flexible opposite. Behavior-preserving for
WellplateMultiPointWidget and FlexibleMultiPointWidget; needed so
RecordZStackMultiPointWidget can reuse the mixin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…label

Task 5's review caught that "Record/Z-Stack" doesn't match the real tab
label "Record + Z-Stack" (gui_hcs.py) — would confuse users once Task 7
wires the widget into the dialog.
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>
_apply_yaml_settings blocks checkbox_time's toggled signal while loading,
so the normal _on_time_toggled -> _update_tab_styles path never fires.
Fix Round 1 already restored checkbox state and frame visibility but left
the frame's border/background stylesheet stale. _update_tab_styles() has
no interaction with Nt/dt or _stored_time_params, so it's safe to call
directly in the finally block.
Implement Task 8 of the Record/Z-Stack feature. Add two buttons to the
output group that allow users to save and load full acquisition settings
via file dialogs:

- "Save Settings…" button calls _on_save_settings_clicked to save
  current widget state (calls _update_scan_regions, builds parameters,
  and calls _save_record_zstack_yaml)
- "Load Settings…" button calls _on_load_settings_clicked to load from
  a file (calls the mixin's _load_acquisition_yaml)

Both buttons use QFileDialog for file selection. Error handling shows
warning dialog on save failure.

Tests verify button clicks are wired correctly and call underlying
save/load machinery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tests

Save test now hardens its own objectiveStore/camera stubs so the real
_build_objective_info() -> _save_record_zstack_yaml() runs unmocked and
writes a real, parseable YAML file, instead of mocking the save function
itself (a workaround for the shared fixture's un-serializable MagicMocks).

Load test now writes a real, valid record_zstack YAML file and only mocks
QFileDialog.getOpenFileName, so the full parse_acquisition_yaml ->
validate_hardware -> _apply_yaml_settings chain is actually exercised
instead of a hand-rolled substitute.

Also drops a redundant local `from unittest.mock import patch` now that
the load test no longer needs to patch the instance method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…c fields

The test_full_save_load_round_trip_preserves_settings test was only checking
channel names and basic parameters, missing critical assertions on:
- recording_channel object equality (the actual channel object round-trips)
- z-stack channels' numeric fields (exposure_time, analog_gain, illumination_intensity)

Added comprehensive assertions to verify that these values survive YAML
serialization/deserialization round-trips. Updated docstring to accurately
describe what's now tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_apply_yaml_settings()'s finally block resynced time_controls_frame after
signal-blocked mutation (checkbox_time.toggled was blocked during load), but
missed the equivalent for XY: checkbox_xy.toggled and
combobox_xy_mode.currentTextChanged were also blocked, so
_on_xy_mode_changed's visibility refresh never fired. A loaded YAML with
xy_mode="Current Position" left the FOV overlap/shape/size controls visible
even though they don't apply in that mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… drop mixin

_load_acquisition_yaml() (AcquisitionYAMLDropMixin, shared by wellplate/
flexible/record_zstack widgets) called self._apply_yaml_settings(yaml_data)
with no guard. RecordZStackMultiPointWidget is the first (and only) consumer
whose _apply_yaml_settings() calls AcquisitionChannel.model_validate() on
embedded channel dicts, so a syntactically-valid YAML with a malformed
channel (e.g. missing a required field) raised a pydantic ValidationError
straight through the drop/button Qt slot instead of showing a warning.

Wrap the call the same way the existing parse-error handling above it does:
log, show a "Load Error" QMessageBox, and return False. Purely additive for
the success path of all three widgets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_save_record_zstack_yaml() wrapped its open()/yaml.dump() call in a
try/except (OSError, yaml.YAMLError): log.error(...) that swallowed the
error and returned normally. That's correct for nothing on its own -- it
made the interactive "Save Settings..." button handler
(_on_save_settings_clicked) log "Settings saved" and show no warning even
when the file was never written, because its own try/except QMessageBox.warning
guard never got a chance to fire.

Remove the inner swallow and let OSError/yaml.YAMLError propagate. Both real
call sites already handle this correctly one level up:
- run_acquisition()'s snapshot call site already wraps this in
  try/except Exception: log.exception(...), so a failed settings snapshot
  still can't abort a real acquisition.
- _on_save_settings_clicked() already wraps this in
  try/except Exception: QMessageBox.warning(...), which is now reachable.

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>
hongquanli and others added 3 commits July 5, 2026 21:40
…acquisition

# Conflicts:
#	software/control/gui_hcs.py
)

master (PR #584) removed the duck-typed emit_selected_channels() /
display_progress_bar() calls from gui_hcs.onTabChanged and
toggleAcquisitionStart — channels are now emitted by each multipoint
widget at acquisition start. RecordZStackMultiPointWidget's no-op stubs
(added in 9b5be65 to satisfy the old contract) and their regression
test are now unreachable dead code; remove them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ee-run max, not the triggered frame time

The recording phase reads the camera's achievable rate via set_frame_rate(). On
the ToupTek the PRECISE_FRAMERATE option can't be read (E_UNEXPECTED), so
set_frame_rate() fell back to 1000/get_total_frame_time(). get_total_frame_time()
is the *sequential* software/hardware-trigger period (readout + trigger_delay +
exposure) — ~2x the true continuous free-run period — so recordings were capped
at roughly half the camera's real rate: a 20 or 30 fps request clamped to ~14 fps
(and dropping exposure didn't help, since trigger_delay grows to keep the total
pinned).

Add ToupcamCamera._continuous_max_framerate() = 1000/max(readout, exposure),
where readout = strobe_time_us (the exposure-independent sensor readout period),
and return it from set_frame_rate()'s fallbacks. record()'s existing
"honor the requested fps unless the camera can't reach it" logic then works:
requests up to the readout ceiling are honored; higher requests clamp to the
true max.

Triggered-mode timing is untouched — get_strobe_time / get_total_frame_time /
_calculate_strobe_info are unchanged; only set_frame_rate (continuous-only) and
the new helper change.

Verified on a real ITR3CMOS26000KMA: 25 fps x 5 s -> 125 frames (honored);
30 fps x 4 s -> 112 frames at 28.04 fps (the true readout max), where the old
code produced ~56 at the bogus 14 fps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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
Alpaca233 and others added 8 commits July 5, 2026 20:54
Cleanup from the /simplify review (no behavior change):
- Remove the unreachable `if frame_ms > 0` guard and the
  `1000/get_total_frame_time()` fallback. frame_ms is always positive
  (this model's sensor resolutions all map to a positive readout period),
  and that fallback returned the exact triggered-mode value the helper
  exists to reject — a misleading dead branch, not a real safety net.
- De-duplicate the ~2x rationale: set_frame_rate's Returns now points to
  _continuous_max_framerate instead of repeating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hecked phases

Recording phase now starts checked (was unchecked) since it's the more
commonly used mode. Also, unchecked phase groups (Recording, Z-Stack) now
collapse their fields entirely instead of Qt's default checkable-QGroupBox
behavior of graying them out while still showing and reserving space for
them.
…dividers

QGroupBox (even flat) renders a sunken box border under the Fusion style,
inconsistent with WellplateMultiPointWidget's borderless-section convention.
Switch Output/Wells+FOV/Recording/Z-Stack to plain QFrame with a bold QCheckBox
header for the checkable sections, and add a thin sunken HLine divider between
each of the widget's 4 sections so the boundaries stay visually clear.
…ettings

- _build_start_group returned a QGroupBox, the one section still showing the
  sunken-box look the other 4 sections were switched away from; make it a
  plain QFrame like the rest.
- Move the Save/Load Settings buttons out of the output group and into the
  start group (just above Start Acquisition), and hide them by default —
  drag-and-drop of an acquisition YAML already covers the same functionality
  without permanently using UI space.
… YAML load; fix double-indented content

Code review of the QGroupBox->QFrame refactor found two regressions:

- _apply_yaml_settings blocks checkbox_recording/checkbox_zstack's toggled
  signal while calling setChecked(), so the collapse-when-unchecked wiring
  in _build_recording_group/_build_zstack_group never fired on load, leaving
  each section's visible/hidden state stale relative to its checkbox. Store
  the content vbox/grid layout as attributes and resync visibility directly
  in the finally block, matching the existing checkbox_time/checkbox_xy
  pattern. Added a regression test.
- Both sections' content layout set its own (4, 2, 4, 2) margins on top of
  the new outer_vbox's identical margins, double-indenting the channel
  table/fields relative to the checkbox header and the other 3 sections.
  Zero the inner layout's margins since the outer frame margin already
  applies.
recording_channel_table's fixed height was computed from rowHeight(0) before
the row's cell widgets (combo/spinboxes/button, all taller than plain text)
were placed, then padded by 8px to guess enough room for them — leaving
visible empty space below the row once they were actually added. Move the
height calculation after the cell widgets are set and call
resizeRowsToContents() first, so the fixed height matches the row's real
final height.
…d wrong channel

- Recording/Z-Stack channel table headers were rendering bold (default header
  font weight); explicitly set them to normal weight.
- _copy_recording_from_live and _copy_zstack_row_from_live read
  liveController.currentConfiguration — whatever channel happens to be active
  in the Live tab — instead of the settings for the channel actually selected
  in the recording row / z-stack row being refreshed. This silently switched
  the recording row's channel selection to match Live's active channel, and
  overwrote z-stack rows with the wrong channel's values whenever Live wasn't
  showing that row's channel. Both now look up settings via _channel_settings()
  for their own channel instead.
…RY header/test fixes

Code review of e57dbb1d found:

- _copy_recording_from_live/_copy_zstack_row_from_live switched to
  _channel_settings(name), which silently falls back to a hardcoded
  (50, 0, 50) triple when the channel isn't found (e.g. the objective changed
  elsewhere and the row's stale channel selection no longer exists) — a quiet
  data-reset the old currentConfiguration-based code never had. Use
  _find_channel() directly and leave the row untouched (with a warning) on a
  miss instead. Added a regression test.
- test_copy_from_live_uses_selected_channels_own_settings didn't actually
  exercise the button's click handler, since selecting the channel via
  setCurrentText() already auto-seeds the same target values through
  _on_recording_channel_changed. Overwrite the spinboxes before clicking so
  the assertions can only pass if the click handler itself re-applies them.
- Factored the duplicated 3-line header-unbold snippet (recording + z-stack
  tables) into a shared _set_header_not_bold() helper.
- recording_channel_table's tightened fixed-height padding used a bare "+2"
  magic number; use 2 * frameWidth() (the table's actual top+bottom border)
  instead, so it stays correct if the frame style ever changes.
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.

3 participants