Skip to content

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

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

feat: Record + Z-Stack acquisition mode#564
hongquanli wants to merge 103 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

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.")
hongquanli and others added 14 commits July 27, 2026 14:20
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>
Alpaca233 and others added 13 commits July 27, 2026 14:24
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.
…trigger live can't break recording

Starting a recording while Live ran in SOFTWARE-trigger mode aborted the run on
real hardware. _probe_frame_shape() sized the recording dataset by sending a
software trigger, but Live's last trigger was still outstanding
(get_ready_for_trigger() == False), so send_trigger() was rejected ("trigger too
early"). The probe then fell back to get_resolution() — the raw binned sensor
size (3104x2084) — while the camera delivers cropped 2084x2084 frames, so every
write_frame failed with a dimension-alignment error and the store sealed
incomplete, fail-fasting the whole acquisition.

Probe in CONTINUOUS mode instead — the same mode recording uses — and read one
free-run frame (no send_trigger, so trigger readiness can't block it); the probed
shape is then exactly what recording writes. On a genuine probe failure, abort
with a clear error instead of sizing from get_resolution() and grinding out a
blank, incomplete recording.

Also fix SimulatedCamera.read_camera_frame() to wait for a frame (matching the
real camera / AbstractCamera contract) rather than returning None before the
first CONTINUOUS frame — otherwise the continuous probe gets no frame in sim.

Verified on a real ITR3CMOS26000KMA: the continuous probe returns (2084, 2084)
(matching delivery) vs get_resolution()'s (3104, 2084), and get_ready_for_trigger()
is False right after a software trigger (reproducing the reject). New tests cover
the probe using CONTINUOUS/never triggering and aborting (not get_resolution) on
no frame.

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

Previously the button was always clickable, and clicking with both phases
disabled just surfaced validate()'s "At least one phase must be enabled"
error via a dialog after the fact. Disable the button proactively instead,
kept in sync via checkbox_recording/checkbox_zstack toggled signals and
resynced after YAML load (same signal-blocking issue as the earlier
Recording/Z-Stack section-visibility fix).
RecordZStackMultiPointWidget was the only multipoint tab setting
QFrame.Panel | QFrame.Raised on itself — WellplateMultiPointWidget and
FlexibleMultiPointWidget use the default QFrame.NoFrame. The raised panel
gave this tab a visibly different embossed background/border compared to
the others.
…ts own gray fill

The raised-panel removal (9907f81) wasn't the actual cause: WellplateMultiPointWidget
also sets QFrame.Panel | QFrame.Raised, but it doesn't use a QScrollArea. This
widget's QScrollArea viewport and content QWidget both auto-fill an opaque
palette-Window background, visibly darker than the tab pane under Fusion.
Disable auto-fill on both so the pane's own background shows through, matching
every other multipoint tab.
Rebasing this branch onto master replays its 86 commits linearly, which
drops the 8 merge commits — including the two `Merge origin/master` merges
and the feat/multi-plane-recording merges. The conflict resolutions those
merges carried are not reproducible commit-by-commit, so replaying the
older side of each pair silently reverted work that was already settled.

This restores the tree to exactly what `git merge upstream/master` produces
from the pre-rebase tip, i.e. the reviewed content of the PR:

- multi_point_worker.py — keep `_abort_cause` / `_abort_due_to_error()` /
  `_run_state_beat()` hoisted into MultiPointWorkerBase, and the frame
  callback's `_abort_due_to_error()` calls, rather than the pre-merge
  `request_abort_fn()` form that the extract-base commit had moved verbatim.
- widgets.py, acquisition_yaml_loader.py, record_zstack_controller.py —
  keep the multi-plane recording fields (Nz / dz / bottom-Z offset) that
  superseded the single `recording_z_offset_um`, plus the
  `_update_recording_planes_ui()` resync after a YAML load.
- gui_hcs.py — drop the `emit_selected_channels()` call that a later commit
  had already removed in favour of `_tab_uses_well_selector()`.
- tests — matching updates and test ordering.

Verified: this branch's tree is now byte-identical to the clean merge of
upstream/master into the pre-rebase tip (0afae24).
@Alpaca233
Alpaca233 force-pushed the feat/record-zstack-acquisition branch from 0afae24 to d07b875 Compare July 27, 2026 21:31
Alpaca233 and others added 16 commits July 27, 2026 15:34
…eview cleanups)

Four-angle code review (reuse / simplification / efficiency / altitude) of the
Record + Z-Stack PR diff; net -68 lines with no behavior change on any tested
path.

Reuse — one copy instead of two:
- New PrewarmedJobRunnerSlot (acquisition_setup.py) owns the warm-up/consume/
  re-warm/shutdown lifecycle; MultiPointController and RecordZStackController
  both delegate to it instead of carrying verbatim copies of the trio.
- MultiPointWorkerBase gains _create_backpressure, _channel_zarr_metadata and
  _adopt_or_create_job_runner; MultiPointWorker.__init__ and
  RecordZStackWorker._setup_zstack_job_runner now share the backpressure
  construction, channel-metadata extraction, and the adopt-or-create runner
  dance (incl. the hung-warmup shutdown path) they had each re-implemented.
- _move_z_to_offset and both phases' Z-restores delegate to the inherited
  move_to_z_level (whose blocking stage move already includes the MCU wait).
- _parse_well_name keeps its strict regex but delegates the bijective base-26
  math to mosaic_utils.parse_well_id (was a third copy of the row arithmetic).
- _build_objective_info uses compute_pixel_size_um instead of an inline copy.
- parse_acquisition_yaml calls _parse_camera_binning instead of keeping the
  identical inline block 80 lines below the helper.
- QtRecordZStackController builds callbacks via
  dataclasses.replace(NoOpCallbacks, ...) instead of a 7-lambda copy; comment
  corrected (the base machinery does emit other callbacks — deliberately
  dropped, no live-display routing for this mode yet).
- _browse_saving_dir now persists via save_last_used_saving_path, matching the
  sibling widgets it re-implements (the widget already read the value at init).

Altitude — validation and public surfaces:
- validate_record_zstack_params moved from widgets.py to
  record_zstack_controller.py beside the params dataclass and geometry helpers
  it validates (widget's validate() delegates; controller-side enforcement in
  run_acquisition deliberately NOT added — zero-FOV runs are a tested
  contract).
- The confirm-recording dialog derives its plane range from
  recording_plane_offsets_um — the same helper the worker records with.
- gui_hcs.onTabChanged calls the widget's public on_well_selection_changed()
  instead of reaching into _update_scan_regions().

Efficiency — per-FOV and hot-path waste:
- The achievable-fps probe runs once per acquisition (exposure is fixed, the
  clamp can't change between FOVs); later FOVs just re-assert the cached rate
  after their mode switch. The old "re-apply clamped rate" block — a hardware
  no-op on every camera implementation — is gone, as is the per-FOV repeat of
  the clamp warning.
- zstack() skips set_trigger_mode(SOFTWARE) when the mode is already SOFTWARE
  (every FOV after the first): the camera side is re-asserted by record()'s
  finally, so the repeat was a pure MCU round trip.
- RecordZStackController consumes the pre-warmed runner only when the z-stack
  phase will dispatch jobs; recording-only runs no longer orphan a runner and
  spawn a replacement subprocess for nothing.
- StreamingCapture resolves stop_condition.expected() once at construction
  instead of hasattr-probing on every hot-path frame callback.

Simplification — dead state and derivable state:
- _zstack_channel_names is now a property derived from the table (single
  source of truth); add/remove row no longer double-bookkeep a parallel list.
- The recording-row accessors read the stored spinbox/combo attributes
  directly; the cellWidget() lookups and unreachable fallbacks are gone.
- Timepoint pacing computes the deadline once in _pace_timepoint and passes it
  to _wait_for_dt; the never-None _acq_start_time fallbacks are gone, as is
  zstack()'s redundant re-write of self.time_point.

Tests updated mechanically (validator import/patch target, _wait_for_dt
deadline arg, mock controller slot). Full battery matches the pre-cleanup
baseline exactly: worker 32 passed + the 2 pre-existing smoke failures, widget
100, streaming/fps/yaml 165, backpressure+laser-AF 103, GUI 7, control/core
285, MultiPointController/Worker 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…writer_info on the in-process job path

Three layered root causes behind test_record_zstack_worker_smoke /
test_record_zstack_controller_smoke failing (and silently wedging) on machines
with tensorstore installed:

1. Unpinned binning. The test harness pins crop_width/crop_height and asserts
   frames of exactly that size, but SimulatedCamera reports resolution as
   crop/binning — and machine configs that default the camera model to 2x2
   binning (e.g. GXIPY MER2-1220) silently halve every frame, failing the
   shape asserts. _build_simulated_microscope now pins binning to 1x1. CI
   never saw this because importorskip("tensorstore") skips both tests there.

2. Dirty fork -> wedged JobRunner. The controller smoke's pre-warmed JobRunner
   is forked after the worker smoke has already done in-process tensorstore
   I/O (its RecordingWriter); the child inherits tensorstore's initialized
   global state without its worker threads and deadlocks inside ts.open() on
   its first SaveZarrJob. Pre-existing: the parent sat ~2 minutes in bounded
   waits before failing, post-binning-fix it exceeded 5. The controller smoke
   now runs its jobs in-process (USE_MULTIPROCESSING=False, the file's
   existing pattern); the forked-subprocess path stays covered by the worker
   smoke, whose fork happens before any in-process tensorstore use.

3. In-process SaveZarrJob was unrunnable. The frame callback's non-multiprocessing
   fallback calls job.run() directly, but only JobRunner.dispatch() injected
   zarr_writer_info — so ANY ZARR_V3 acquisition with USE_MULTIPROCESSING off
   aborted on its first frame ("requires zarr_writer_info but it is None").
   Workers now keep their ZarrWriterInfo and the fallback injects it before
   job.run(), exactly as the error message prescribes.

tests/core/test_record_zstack_worker.py: 34 passed (was 32 passed / 2 failed),
and the file drops from ~175s to ~64s — the wedged child's bounded waits are
gone. Full battery (MultiPoint controller/worker, per-region laser AF,
backpressure, streaming capture, widget, camera fps, HCS GUI): all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy single-camera RecordingWidget ("Simple Recording" tab) is
superseded by the Record + Z-Stack tab, which covers continuous recording
with channel settings, pacing, multi-plane support, and Zarr output.

Removed:
- widgets.RecordingWidget (the HCS GUI was its only consumer; the
  same-named class in widgets_usbspectrometer.py is a separate,
  self-contained module and is untouched)
- gui_hcs wiring: the "Simple Recording" tab, the widget construction,
  and its now-dead support plumbing — the ImageSaver instance (and its
  background thread), the streamHandler.packet_image_to_write ->
  imageSaver.enqueue connection, and the imageSaver.close() on cleanup;
  nothing else in the HCS GUI drove StreamHandler's recording flags
- tests: TestRecordingWidget (+ fixture/import) in test_widgets.py, and
  test_tab_change_to_simple_recording_does_not_raise, whose subject (a
  record tab without the duck-typed multipoint methods) no longer exists

ENABLE_RECORDING now gates only the Record + Z-Stack tab.

Tests: test_HighContentScreeningGui 6 passed, test_widgets 124 passed,
test_record_zstack_widget 100 passed.

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

Conflict resolutions and follow-ups from master's 13 commits (fluidics
protocol engine #625/#626/#637, coordinate CSV z-coords #608, watchdog
end_reason):

- multi_point_controller.start_new_experiment: keep the create_experiment_dir
  helper and thread master's add_timestamp flag through it (the fluidics
  runner names session folders itself).
- MultiPointWorkerBase: master's end_reason attribute lives on the base
  class next to _abort_cause so RecordZStackWorker classifies identically.
- gui_hcs: keep the Record + Z-Stack widget construction and master's
  _setup_fluidics_widgets(); drop the removed multiPointWithFluidicsWidget
  refresh.
- test_widgets: RecordingWidget import dropped (Simple Recording was removed
  on this branch).
- acquisition_yaml_loader.parse_acquisition_yaml: master's coordinates.csv
  completion assumed wellplate|flexible and raised AttributeError on
  record_zstack YAMLs (RecordZStackYAMLData has no flexible_positions),
  which surfaced as a modal "Load Error" dialog on Load Settings.
- test_recording_path_plane_naming: normalise os.sep so it passes on Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
…m is stopped, bound it by exposure

Bench-tested on an ITR3CMOS26000KMA (camera-only bench, everything else
simulated). Two things the simulation could not show:

1. The MIN/MAX_PRECISE_FRAMERATE *reads* fail with E_UNEXPECTED whenever
   the pull-mode stream is stopped (Toupcam_Stop), but the PRECISE_FRAMERATE
   *write* is accepted and takes effect on the next Start.  RecordZStackWorker
   always stops the stream (frame-shape probe) before record() calls
   set_frame_rate, so the range read failed every time, the hint was silently
   dropped, and the sensor free-ran at 28 fps with the RecordingRouter
   downsampling in software (2.8x the frame traffic, +-18 ms slot jitter).
   The existing test even pinned this as "option unavailable on this camera".
   Now the range is read once at construction (stream just started) and on
   every later successful read, and set_frame_rate falls back to the cached
   range while stopped and always writes the option.  Verified: 10 fps
   requested -> exactly 30 frames / 3 s delivered, 100.0 ms spacing.

2. MAX_PRECISE_FRAMERATE ignores the exposure time: at 100 ms exposure the
   camera still reports 28 fps max, and set_frame_rate(30) returned 28 while
   the sensor delivered 10 fps.  The worker sizes the dataset from that return
   value, so the recording would have sealed incomplete with blank planes.
   The returned rate is now min(clamped precise rate, exposure/readout-
   limited continuous max): 100 ms @ 30 fps requested -> 10.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
…rdings sequentially

Bench-tested on an ITR3CMOS26000KMA (2x2 binning, 2084x2084 MONO16 =
8.3 MB/frame, NVMe).  Before: 20 fps was the ceiling — 25 fps overran the
2 GB queue after ~28 s and fail-fast aborted, and the camera's 28 fps
maximum never sealed complete.  After: every mode records at the camera
maximum with complete stores (6.8 fps at 1x1/16-bit, 28 at 2x2/16-bit,
37 at 2x2/8-bit, 63 at 3x3/16-bit, 108 at 3x3/8-bit; 240 MB/s sustained).

Two independent limits fell:

1. RecordingWriter drained synchronously: ZarrWriter.write_frame() blocks on
   every future, so encode / file create / write / fsync never overlapped and
   the drain topped out at ~25 frames/s of 8 MB frames (~10 with the camera
   thread competing).  ZarrWriter gains submit_frame() (returns the write
   future; write_frame() is now submit + wait) and the drain keeps up to
   max_inflight=8 submissions outstanding, reaping the oldest before each
   new one and every one before sealing, so errors are still counted and
   abort()/finalize() never race in-flight I/O.  Micro-benchmark: 25 -> 120+
   frames/s at 4 in flight with fsync semantics unchanged.

2. RecordingRouter mapped frames to the slot nearest their host arrival
   time and rejected "duplicates".  At the camera's maximum the sensor
   free-runs ~2% off the nominal rate, so a frame every ~25-50 was rejected
   (807/840, 834/840) and each store sealed incomplete; a GUI repaint holding
   the GIL for >50 ms did the same at 10 fps.  The worker now tells the
   router when the camera reported it will deliver at or below the requested
   rate (it paces itself to the hint, or the request is its maximum): every
   frame is then wanted and slots are filled sequentially, never rejected.
   Only a gap >= max(1.75 periods, 100 ms) is a stall (logged, holes kept).
   Cameras that report a HIGHER free-run rate keep the old downsampling
   regime, test-pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
…-applied once the stream runs

Follow-up to the earlier PRECISE_FRAMERATE fix, from bench runs across
binning / bit-depth modes on an ITR3CMOS26000KMA:

- The (min, max) range depends on resolution and bit depth (2x2 16-bit:
  1.9-28 fps; 3x3 8-bit: 4.8-108.5 fps).  The cached range is now keyed on
  (width, height, bytes/pixel) and dropped on a mode change, and refreshed
  every time the pull-mode stream starts, so a stopped-stream
  set_frame_rate() after a binning change no longer clamps the new mode to
  the old maximum (3x3 modes were capped at 28 fps).
- With no cached range the request is bounded by the readout-limited
  maximum (within 1.5% of the SDK's value in every mode measured) and
  written anyway.  The SDK validates a stopped-state write against the
  range of the mode it LAST streamed in and, on rejection, keeps the
  previous pacing (no free-run fallback), so ContinuousFrameSource.start()
  now re-applies the hint right after start_streaming(), where the range is
  always current.  The pre-start probe still sizes the dataset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kp5ydXnD81T4cRbMJbUnRA
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
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