feat: per-channel z-offset for laser autofocus - #551
Conversation
Specifies the data model (reuse AcquisitionChannel.z_offset_um), delta-tracking acquisition algorithm with backlash inheritance, UI in LiveControlWidget / LaserAutofocusSettingWidget / acquisition widgets, sample-dependent reference handling, and test strategy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses critical and major findings from independent review: - C1: piezo path now dispatched via _move_z_for_offset (stage vs piezo) with range clamping; matches move_z_for_stack's piezo branch. - C2: z-level body wrapped in try/finally so _reset_channel_z_offset runs on abort/exception; handle_acquisition_abort also resets defensively. - C3: explicit invariant that _current_z_offset_um == 0 at every point where acquire_pos / _last_time_point_z_pos / stack helpers run. - M1: repository.py:761-782 create-from-general path will be fixed to copy z_offset_um (latent bug exposed by the feature). - M2: backlash caveat for < 5 µm regime near soft limits documented. - M3: test plan extended with piezo, time-lapse, abort, multi-region, and the create-from-general persistence path. - M4: in-memory currentConfiguration.z_offset_um mutation pattern made explicit. - Minor: MultiPointWithFluidicsWidget added to UI scope; live-view uses absolute positioning to be robust against manual z jogs; checkbox-read timing for MultiPointController clarified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 tasks across 6 phases: 1-2: Repository fix + ZOffset setting key (TDD) 3-4: Model description, signal_reference_changed 5: AcquisitionParameters plumbing 6-9: Worker logic — helpers, loop integration, abort handling, logging 10-12: Acquisition widget checkboxes (FlexibleMultiPoint, Wellplate, WithFluidics) 13-14: LiveControlWidget UI (toggle + spinbox + capture + reset + apply-on-switch) 15: NapariLiveWidget mirror 16: LaserAutofocusSettingWidget Reset All 17: End-to-end simulation smoke Each task has TDD-style steps (failing test → implementation → verify → commit) where unit-testable; UI tasks rely on the final simulation smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…from general When update_channel_setting creates an objective config from the general config (because no objective-specific file exists yet), the AcquisitionChannel constructor omitted z_offset_um, silently resetting it to 0.0. Added z_offset_um=ch.z_offset_um to the comprehension so per-channel laser-AF offsets survive the first objective-level write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… and worker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…andle_z_offset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ultiPointWidget Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…WithFluidicsWidget Mirrors the pattern from Tasks 10-11: declares checkbox_applyChannelOffset, adds it inside the SUPPORT_LASER_AUTOFOCUS layout block, wires the enable-state helper to checkbox_withReflectionAutofocus.toggled, syncs initial state, and adds _update_apply_channel_offset_enable_state / _on_apply_channel_offset_changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Widget Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…trolWidget Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "wired to behavior in Task 14" comment was left over from development notes and adds no information to a reader unfamiliar with the task plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion widgets Extract the two duplicated laser-AF per-channel z-offset methods into a _ApplyChannelOffsetMixin and have FlexibleMultiPointWidget, WellplateMultiPointWidget, and MultiPointWithFluidicsWidget inherit it, eliminating three copies of the same code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e positioning Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ainst stage errors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… handler Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l offsets Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four fixes to LiveControlWidget surfaced by the PR #551 review: * capture_current_z_offset: laser_af.measure_displacement() returns float('nan') on a soft failure (laser-on timeout, no spot, invalid centroid) rather than raising, so the existing try/except did not catch it. Capture would persist NaN to the channel YAML, breaking every subsequent acquisition. Now validates math.isfinite and warns the user. Also rejects values outside the spinbox range (the spinbox would silently clamp setValue, leaving model and UI in disagreement). * _maybe_apply_live_channel_offset: same NaN path could feed stage.move_z_to(NaN). Added isfinite checks on both the AF reading and the stored channel offset. Also added a magnitude bound (_LIVE_OFFSET_MAX_JUMP_UM = 500 µm): a wildly wrong AF reading (drift, secondary peak, stale reference) could otherwise drive an unbounded millimetre-scale absolute move on every channel switch. * update_ui_for_mode / refresh_z_offset_from_config: the 'value or 0.0' idiom treats NaN as truthy and propagates it into setValue. Replaced with a _safe_z_offset_value helper so a pre-existing NaN in config doesn't poison the spinbox. * _reset_all_channel_offsets: signal_channel_offsets_reset was emitted only when every channel update succeeded. On partial failure the channels that DID reset left LiveControlWidget's spinbox showing a stale non-zero offset, which a subsequent edit would re-persist. Now emitted whenever any channel reset succeeded; the warning dialog still fires for the failed ones. Four new regression tests cover the NaN-from-AF, NaN-from-config, magnitude-cap, and small-move-still-allowed cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Copilot review feedback on PR #551: * Microscope: add public `laser_autofocus_controller` property exposing the lazily-initialized `_laser_af_controller`. LiveControlWidget reads via `liveController.microscope.laser_autofocus_controller`; before this fix the attribute didn't exist, getattr returned None, and the new Capture / Apply-on-channel-switch / Reset-all controls silently no-op'd in the real GUI. * gui_hcs: pre-populate Microscope's controller slot with the GUI's LaserAutofocusController instance so both call paths share the same object (lazy init in Microscope.perform_laser_af stays available for headless scripts). * laser_auto_focus_controller.initialize_manual: emit signal_reference_changed at the end so widgets stay in sync when on_settings_changed / load_cached_configuration reload a config whose has_reference differs from the previous one. The previous emissions in initialize_auto / set_reference still fire; the new emit covers the reload path that Copilot's suppressed comment called out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'Capture current' label was vague — capture what's current? The button
reads the live laser-AF displacement and saves it as this channel's z-offset,
which is more naturally framed as 'use the current focus as the offset'.
Renames the button and the related dialog titles ('Capture failed' →
'Reading failed', 'Capture out of range' → 'Reading out of range') for
consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… AF off
* LiveControlWidget per-channel Z-offset row:
- 'Apply on channel switch' → 'Apply in Live' (clearer scope: applies
while in live view, on channel switch).
* Acquisition-widget checkbox (Flexible/Wellplate/Fluidics):
- 'Apply per-channel z-offset' → 'Per-channel Z-offset'.
- Hidden + unchecked when reflection AF is off (previously just disabled);
the feature is meaningless without an AF anchor, so hiding makes the
dependency obvious and clears the controller flag so it can't be a
silent opt-in once AF is later turned on.
* Z-offset string casing unified to 'Z-offset' (capital Z, hyphen, lowercase
'o') across user-visible labels, buttons, tooltips, and dialogs:
- Button 'Reset all channel offsets' → 'Reset all channel Z-offsets'.
- Dialog title 'Reset channel offsets' → 'Reset channel Z-offsets'.
- QLabel 'Z offset:' → 'Z-offset:'.
- Spinbox/button tooltips and dialog bodies use 'Z-offset'.
Log messages, internal comments, and Python identifiers keep their
lowercase 'z-offset' / 'z_offset_um' forms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These docs are about the implementation process and live with other in-progress AI work-product, not in the Squid source tree. Moved to AI-docs/Squid/in-progress/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ctive
User-reported: after clicking 'Use Current' the captured offset never
applied at acquisition time. Root cause: update_channel_setting('ZOffset')
writes to the OBJECTIVE config (z_offset_um is per-objective), but
merge_channel_configs pulled the value from the GENERAL config, so every
acquisition read the stale general value and ignored the captured offset.
Other per-objective fields (exposure, gain, pixel_format) already merge
from the objective channel; this brings z_offset_um in line with them.
Tests in test_acquisition_config_models.py and test_utils.py were
asserting the bugged behavior (objective channel constructed without
z_offset_um, defaulting to 0.0, with the merge expected to fall back to
general). Updated to mirror the real auto-create-from-general flow that
copies z_offset_um into the objective channel, and added an explicit
regression test that asserts a captured objective offset wins over a
stale general one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported: clicking Use Current while live view was running returned
NaN; manually stopping live first made it work. Root cause: laser AF's
measure_displacement() issues turn_on_AF_laser via the microcontroller
and waits for the operation-completed ack, while live continuously queues
trigger commands on the same serial link. The two contend and the wait
times out, so measure_displacement returns float('nan').
capture_current_z_offset now records was_live, stops live for the
measurement window, and restarts it in a finally block so live resumes
even when measure_displacement raises or returns NaN. The live restart
fires before any subsequent warning dialog so the user doesn't stare at
a frozen frame while dismissing the message.
Not applied to _maybe_apply_live_channel_offset: that path is the
'Apply in Live' feature itself, and stopping live on every channel
switch would defeat its purpose. If users hit NaN there too we can
revisit, but the existing NaN guard prevents bad stage moves regardless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…olWidget Removes the 'Reset all channel Z-offsets' button from the laser AF settings panel and adds a 'Reset All' button next to the existing 'Reset' button in LiveControlWidget's Z-offset row. Co-locating the bulk-reset action with the per-channel reset matches how users mentally group these operations and removes the cross-widget signal indirection. * New layout: Z-offset: [spinbox] [Use Current] [Reset] [Reset All] [Apply in Live] * _reset_all_channel_z_offsets() moved into LiveControlWidget and refreshes the spinbox directly instead of going through signal_channel_offsets_reset. * signal_channel_offsets_reset removed (no remaining consumers) and the gui_hcs.py wiring that connected it dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'Apply in Live' is clipped at the right edge of the Z-offset row because the default QPushButton sizing for 'Reset' (~70px) plus 'Reset All' (~85px) leaves no room. Fixed widths of 55px / 75px give just enough padding around the text and free ~25px for the checkbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ive-restart Two code-review follow-ups from PR #551 that survived the dialog Z-offset column revert: * Replace setFixedWidth(55)/(75) on the Reset and Reset All buttons with a setMaximumWidth derived from fontMetrics().horizontalAdvance(text) + 16 px padding. The fixed pixel values would clip 'Reset All' on HiDPI displays and accessibility-large-text setups. * capture_current_z_offset: - Wrap stop_live() in try/except so a failure shutting down live doesn't propagate before the finally block enters; the finally still attempts start_live() so we don't leave live in a stuck-stopped state. - Emit signal_start_live after the programmatic start_live() so subscribers (tab switch / alignment widget enable) re-fire — matches what toggle_live() does when the user clicks the button manually. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…int for buttons Two follow-ups to 42b8758 surfaced by code review: * Remove signal_start_live.emit() from capture_current_z_offset's finally. Its subscriber onStartLive (gui_hcs.py) unconditionally calls imageDisplayTabs.setCurrentIndex(0), so a user clicking 'Use Current' from a non-Live tab (Multichannel Acquisition, Mosaic, NDViewer, Laser-Based Focus) was being yanked back to the Live View tab. Even worse, on the laser-focus tab the cascading onDisplayTabChanged(0) shuts down the focus-camera preview the user is monitoring. start_live() alone is enough to resume the camera stream; toggle_live()'s other side effects are user-press-driven and not appropriate here. * Switch _shrink_btn_to_text from fontMetrics+16px-padding to setFixedWidth(btn.sizeHint().width()). sizeHint already includes the active QStyle's button margin, frame thickness, focus rect, font, and icon — so the label cannot clip on themes where the chrome exceeds the flat 16px. Matches the existing repo pattern at widgets.py:12969/12990 and removes the setMaximumWidth-without-setMinimumWidth regression (layout pressure could otherwise compress the button below text width). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| self._log.warning( | ||
| f"Channel '{config.name}' has non-finite z_offset_um={raw_target!r}; " | ||
| f"treating as 0 and skipping the move" | ||
| ) | ||
| target_um = 0.0 |
There was a problem hiding this comment.
[Claude Code] Fixed in 7ec3d8b — kept the 'treating as 0' fall-through (it correctly resets the stage to baseline when a prior channel applied an offset), but reworded the log to 'treating as 0 (will reset to the un-offset baseline if a prior channel already applied an offset)' so the message matches the actual behavior.
| initial_has_ref = False | ||
|
|
||
| self.checkbox_applyOnChannelSwitch.setEnabled(initial_has_ref) | ||
| self.checkbox_applyOnChannelSwitch.setChecked(initial_has_ref) | ||
| self.btn_captureZOffset.setEnabled(initial_has_ref) |
There was a problem hiding this comment.
[Claude Code] Fixed in 7ec3d8b — removed setChecked(initial_has_ref) so 'Apply in Live' is now opt-in regardless of laser AF reference state. setEnabled is still gated on has_reference, so the control is visible/clickable only when a reference exists, but never auto-fires a stage move on the next channel switch.
…t + NaN log * widgets.py: stop auto-checking checkbox_applyOnChannelSwitch when laser AF reports an existing reference. Auto-check made 'Apply in Live' effectively opt-out — a user with a previously-set reference would get an absolute Z move on the next channel switch with no prior explicit consent. Keep setEnabled() gated on has_reference; leave checked state to the user. * multi_point_worker.py: fix the misleading warning for a non-finite channel z_offset_um. The previous text said 'treating as 0 and skipping the move', but the code still issues a move when _current_z_offset_um is non-zero (delta = 0 - prior_offset). Reworded to say it resets to the un-offset baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…an at start
User report: 'the width of Reset button did not change and offsets are still
not being applied'.
Width: my prior 'use sizeHint()' change set the button to its natural
Qt-layout width, which is *wider* than the previous setFixedWidth(55) — so
the user saw no shrinkage. The whole point of the constraint was to push
the buttons *below* sizeHint so 'Apply in Live' fits in the row. Reverting
to the empirically-verified 55/75 inline; dropping the unused
_shrink_btn_to_text helper that no longer made sense.
Diagnostics: extend _log_ignored_offsets to also log when the gate is OPEN
and non-zero offsets exist ('Per-channel z-offsets will be applied: [...]').
The previous version logged only when offsets were ignored. The new positive
log lets users confirm at acquisition start that the worker saw the captured
offsets — useful when the symptom is 'offsets not applied' but the cause is
elsewhere (laser AF reference not set, sim AF failing, etc).
Updated test_log_ignored_offsets_silent_on_happy_path → now
test_log_ignored_offsets_logs_will_apply_on_happy_path to match the new
behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: 'af should not work on channel switch. only the offset need to be applied based on the current position of offset = 0.' Rewrites _maybe_apply_live_channel_offset to drop the laser AF call and apply the stored per-channel offset as a RELATIVE delta against a tracker of the currently-applied offset: * Tracker (_live_current_z_offset_um) starts at 0 and is reset to 0 each time the user enables 'Apply in Live' — treating the current stage z as the offset=0 baseline. * On each subsequent channel switch with the box checked, the helper reads new_config.z_offset_um, computes delta = target - tracker, and issues stage.move_z(delta/1000) (relative). On success the tracker is updated to the new target so the next switch only moves by the difference between consecutive channels' offsets. * Gates retained: 'Apply in Live' checkbox checked; laser AF has a reference (offsets come from AF capture); stored offset is finite; |delta| <= safety cap (500 µm). * No measure_displacement() call, no absolute move_z_to — the switch is pure delta-tracking and doesn't disturb the user's chosen focal plane beyond the relative offset between channels. This matches the worker's delta-tracking semantics inside an FOV (sans the per-FOV AF anchor) and avoids the failure mode where measure_displacement returning NaN (or AF taking seconds) blocked switches in live view. Tests rewritten to cover delta tracking, sequence accumulation, tracker reset on enable, safety cap on delta, and that measure_displacement is NOT called on switch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t is applied Per user request: 'if setting reference in a live channel with offset, set reference at the 0 offset position and then move back to the offset position.' When the user clicks Set Reference while a per-channel z-offset is currently applied via 'Apply in Live', the AF widget now: 1. Stops live (existing behavior). 2. If LiveControlWidget.applied_channel_z_offset_um != 0, steps the stage by -applied_offset_um to reach the offset=0 baseline. 3. Calls laser_af.set_reference() — the reference plane is now anchored at the un-offset z so the other channels' stored offsets remain valid relative deltas from it. 4. Restores the stage by +applied_offset_um so the user's view returns to the channel offset position they were inspecting. 5. Restarts live. Mechanism: - LiveControlWidget exposes applied_channel_z_offset_um as a read-only property over the existing _live_current_z_offset_um tracker. - LaserAutofocusControlWidget gains an optional liveControlWidget kwarg (None-safe for tests / contexts that lack the live widget); gui_hcs passes self.liveControlWidget at construction. - Move-out and move-back failures are logged at warning level; if the baseline move fails, the reference is captured at the current (offset) position instead of leaving the stage stranded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirmed via an end-to-end sim reproduction: move_to_target reports soft failures (no reference, NaN displacement, displacement out of range, cross-correlation mismatch) through its bool RETURN VALUE, not by raising. perform_autofocus only caught exceptions, so on every soft failure it: * incremented _laser_af_successes (Slack stats counted failures as wins), * left _last_af_succeeded True — making the per-FOV z-offset gate dead code: per-channel offsets were applied relative to an unanchored z, exactly what the gate was added to prevent, * never triggered acquire_at_position's 'Autofocus failed... continuing anyway' diagnostic. perform_autofocus now records the return value, routes both soft failures and exceptions through the same failure bookkeeping (failure counter, _last_af_succeeded=False, return False), and only counts a success when move_to_target actually returned True. The focus-camera image dump stays on the exception path. Repro (sim, reference image set but reference position unset → soft failure each FOV): before the fix the worker issued ±3.5 µm offset moves on every FOV with af_ok=True; after, the gate suppresses them with the per-FOV warning. Adds three unit tests covering the soft-failure, success, and exception paths of the reflection-AF branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or change)
Cleanups surfaced by /simplify across the laser-AF z-offset feature:
worker (multi_point_worker.py):
* Replace the _last_af_succeeded instance flag with an explicit af_succeeded
parameter threaded from acquire_at_position's existing perform_autofocus
return value into _apply_channel_z_offset. Removes hidden temporal state
(the flag was pre-set in one method and read in another) and the optimistic
default. perform_autofocus just returns its bool again.
widgets.py (LiveControlWidget):
* Extract _persist_z_offset() — collapses three identical
update_channel_setting('ZOffset', ...) call blocks (update_config/capture/
reset) into one; callers keep their own failure surfacing.
* Extract _set_z_offset_spinbox_silently() — collapses three copies of the
is_switching_mode try/finally spinbox-update guard.
* Flatten the nested try/try/except/finally in capture_current_z_offset to a
single try/except/finally (identical semantics).
* Trim the multi-line "values empirically verified on this user's display"
button-width comment to a constraint statement.
widgets.py (_ApplyChannelOffsetMixin):
* Add _create_apply_channel_offset_checkbox() and move the checkbox tooltip
into a class constant; the three multipoint widgets each dropped a
copy-pasted 6-line creation block. Also drop the redundant per-toggle
setToolTip in _update_apply_channel_offset_enable_state.
laser_auto_focus_controller.py:
* Trim the 6-line emit-ordering narration comment to a one-line invariant.
Tests updated for the _apply_channel_z_offset signature (stub wrapper defaults
af_succeeded=True; the gate test and perform_autofocus tests pass/assert the
boolean explicitly).
Skipped (noted): batching _reset_all_channel_z_offsets into a single objective
YAML write (needs a new repo batch API; rare button, small N); hoisting the
live offset tracker / suspend-live into LiveController and validation into
update_channel_setting (architectural, well beyond this diff).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| self.liveController.microscope.stage.move_z(applied_offset_um / 1000) | ||
| except Exception as e: | ||
| self._log.warning( | ||
| f"Failed to restore stage to offset={applied_offset_um:+.2f} µm " f"after set_reference: {e}" | ||
| ) |
There was a problem hiding this comment.
[Claude Code] Fixed in 85cd92f — added LiveControlWidget.set_applied_channel_z_offset_um and call it on the restore-move failure path so the tracker is reset to 0 (matching the stage left at baseline). Subsequent channel switches now compute deltas from the correct baseline.
| non_zero = [(c.name, c.z_offset_um) for c in self.selected_configurations if (c.z_offset_um or 0.0) != 0.0] | ||
| if not non_zero: | ||
| return | ||
| summary = ", ".join(f"{name}: {off:+.2f}µm" for name, off in non_zero) | ||
| if self.apply_channel_offset and self.do_reflection_af: | ||
| self._log.info(f"[multi-point] Per-channel z-offsets will be applied: [{summary}]") | ||
| return | ||
| reason = "laser AF off" if not self.do_reflection_af else "'Apply channel offset' unchecked" | ||
| self._log.info(f"[multi-point] {reason} — ignoring non-zero z-offsets on channels: [{summary}]") |
There was a problem hiding this comment.
[Claude Code] Fixed in 85cd92f — _log_ignored_offsets now filters the summary to finite offsets and emits a separate warning listing any channels with non-finite z_offset_um, so the 'will be applied' line matches what _apply_channel_z_offset actually does (NaN→0). Added a regression test.
| # z_offset_um is per-objective: focal-plane offset depends on the lens, and | ||
| # the 'Use Current' UI persists into the objective config via | ||
| # update_channel_setting('ZOffset', ...). Pulling from general here would | ||
| # ignore captured offsets and silently break the laser-AF anchor feature. | ||
| z_offset_um=obj_channel.z_offset_um, |
There was a problem hiding this comment.
[Claude Code] Fixed in 85cd92f — updated the merge_channel_configs docstring to list z_offset_um under 'from objective' and added a note on why it's per-objective (persisted via update_channel_setting('ZOffset')).
Three follow-ups from the 2026-06-13 Copilot review on PR #551: * widgets.py: when on_set_reference_clicked successfully steps the stage to the offset=0 baseline but then FAILS to restore it, the stage is left at baseline while LiveControlWidget._live_current_z_offset_um still holds the old applied offset — the next live channel switch would compute a delta from a stale baseline. Added LiveControlWidget.set_applied_channel_z_offset_um and reset the tracker to 0 on restore failure so tracker and stage agree. * multi_point_worker.py: _log_ignored_offsets used `(z or 0.0) != 0.0`, which is truthy for NaN, so a NaN-poisoned config logged 'will be applied' even though _apply_channel_z_offset treats non-finite as 0. Now filters the summary to finite offsets and emits a separate warning listing channels with non-finite z_offset_um. Added a regression test. * acquisition_config.py: merge_channel_configs docstring still said z_offset_um comes from general.yaml; updated to objective (matching the 66070c0 fix) and noted why it's per-objective. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vior change) From /simplify on 85cd92f: * widgets.py: replace the raw set_applied_channel_z_offset_um(value) setter with an intent-named notify_stage_at_baseline() that zeroes the tracker. The only caller passed a literal 0.0 meaning "stage is at baseline", so a named operation encapsulates the tracker invariant instead of exposing a public mutable poke. * multi_point_worker.py: collapse _log_ignored_offsets' two comprehensions (which re-derived `c.z_offset_um or 0.0` five times) into a single pass that normalizes the offset once and partitions into non-finite / finite-non-zero. Skipped (noted): validating non-finite z_offset_um at the persistence boundary (Pydantic validator / update_channel_setting) — changes the model write contract, broader than this diff; and hoisting the set_reference baseline-step into LaserAutofocusController so headless callers share it — substantial widget→controller refactor (recurring architectural theme, out of scope). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## Summary Adds an info-level log line reporting the **actual z position after each per-channel z-offset move** during multi-point acquisition. The log is emitted from `_move_z_for_offset`, so it covers both the **apply** path (`_apply_channel_z_offset`) and the **reset/undo** path (`_reset_channel_z_offset`). Each log records the delta moved and where z landed: - **Stage path:** `[z-offset] moved +1.50 µm via stage; actual z = 3.2000 mm` - **Piezo path:** `[z-offset] moved +1.50 µm via piezo; piezo at 101.50 µm, actual stage z = 3.2000 mm` The stage z is read back with `stage.get_pos()` (the real, measured position) so it can be used to confirm each channel offset actually landed where expected. ## Why When diagnosing per-channel z-offset behavior (PR #551), it's useful to see the resulting z after each offset move rather than only the requested delta. ## Test plan - [x] `pytest tests/control/test_MultiPointWorker_offsets.py` — 25 passed, 1 skipped - [x] `black --config pyproject.toml --check` — clean Test stub's `stage.get_pos()` was given a numeric `z_mm` so the new log f-string formats under the mock. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds a per-channel z-offset (µm) for use with laser reflection autofocus. When laser AF is the active AF method, each acquisition channel can carry a saved offset measured from the laser AF reference plane and have it applied automatically during multi-point acquisition. Optionally, the same offset can be applied on channel switches in live view (absolute positioning, robust to manual z jogs).
Motivation: different fluorescence channels need different best-focus positions due to chromatic aberration and sample-induced effects (refractive index, coverslip / mounting variations). The offset is treated as sample-dependent, not a pure optical constant —
LaserAutofocusSettingWidgetgains a "Reset all channel offsets" button for the new-sample workflow.What's new
MultiPointWorker):_apply_channel_z_offset,_reset_channel_z_offset,_move_z_for_offset) that emit the minimum stage/piezo moves needed to reach each channel's offset relative to the AF reference.try/finallyaround the inner channel loop so the offset is always undone beforemove_z_for_stack/_last_time_point_z_pos/ abort.handle_acquisition_abort.LiveControlWidgetgains a hidden-by-default "Show Z-offset controls" row: spinbox (±50 µm), "Capture current" (readsmeasure_displacement()), "Reset", and "Apply on channel switch" (absolute positioning).FlexibleMultiPointWidget,WellplateMultiPointWidget,MultiPointWithFluidicsWidget) gain an "Apply per-channel z-offset" checkbox via a shared_ApplyChannelOffsetMixin.LaserAutofocusSettingWidgetgains a "Reset all channel offsets" button.apply_channel_offset: bool = TrueonAcquisitionParameters; default keeps TCP/MCP callers backward-compatible."ZOffset"setting key onConfigRepository.update_channel_setting(with alocation == "channel"dispatch branch).signal_reference_changed = Signal(bool)onLaserAutofocusController.repository.py:761-782's "create objective config from general" path was silently droppingz_offset_umwhen constructing a newAcquisitionChannel. The fix is one line; the regression test intest_repository.pywould catch any reintroduction.Skipped on purpose
NapariLiveWidgetwas not updated — it is gated off byUSE_NAPARI_FOR_LIVE_VIEW = Falseand never instantiated. A follow-up cleanup PR should remove the dead widget (notes left inworktrees/docs/2026-05-23-napari-live-widget-dead-code-cleanup.md).Test plan
pytest software/tests/control/test_MultiPointWorker_offsets.py— 14 unit tests (delta tracking, stage + piezo paths, abort, z-stack invariant,_log_ignored_offsets)pytest software/tests/control/test_LiveControlWidget_offset.py— 6 unit tests (absolute positioning, gating, exception path)pytest software/tests/control/core/config/test_repository.py— added 3 tests (M1 regression +ZOffsetround-trip + zero-value persistence)pytest software/tests/control/test_MultiPointController.py— added 2 tests (defaultapply_channel_offset+ override)black --config software/pyproject.toml --check software/— cleanpython3 main_hcs.py --simulation, walk through Show Z-offset toggle → Capture → Reset → run acquisition with two channels at distinct offsets → confirm stage moves match the designDesign docs
software/docs/laser-af-channel-offset-design.md(committed)software/docs/laser-af-channel-offset-plan.md(committed)🤖 Generated with Claude Code