Skip to content

feat: per-channel z-offset for laser autofocus - #551

Merged
Alpaca233 merged 48 commits into
masterfrom
feat/laser-af-channel-offset
Jun 14, 2026
Merged

feat: per-channel z-offset for laser autofocus#551
Alpaca233 merged 48 commits into
masterfrom
feat/laser-af-channel-offset

Conversation

@hongquanli

Copy link
Copy Markdown
Contributor

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 — LaserAutofocusSettingWidget gains a "Reset all channel offsets" button for the new-sample workflow.

What's new

  • Acquisition behavior (MultiPointWorker):
    • Delta-tracking helpers (_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.
    • Tail-correction in try/finally around the inner channel loop so the offset is always undone before move_z_for_stack / _last_time_point_z_pos / abort.
    • Belt-and-braces reset in handle_acquisition_abort.
    • Works for both stage path and piezo path (with range clamping + warning log).
    • Startup log summarising any non-zero offsets that won't be applied this run (laser AF off or checkbox off).
  • UI:
    • LiveControlWidget gains a hidden-by-default "Show Z-offset controls" row: spinbox (±50 µm), "Capture current" (reads measure_displacement()), "Reset", and "Apply on channel switch" (absolute positioning).
    • Three acquisition widgets (FlexibleMultiPointWidget, WellplateMultiPointWidget, MultiPointWithFluidicsWidget) gain an "Apply per-channel z-offset" checkbox via a shared _ApplyChannelOffsetMixin.
    • LaserAutofocusSettingWidget gains a "Reset all channel offsets" button.
  • Plumbing:
    • apply_channel_offset: bool = True on AcquisitionParameters; default keeps TCP/MCP callers backward-compatible.
    • New "ZOffset" setting key on ConfigRepository.update_channel_setting (with a location == "channel" dispatch branch).
    • New signal_reference_changed = Signal(bool) on LaserAutofocusController.
  • Pre-existing bug fix (M1 in design review): repository.py:761-782's "create objective config from general" path was silently dropping z_offset_um when constructing a new AcquisitionChannel. The fix is one line; the regression test in test_repository.py would catch any reintroduction.

Skipped on purpose

NapariLiveWidget was not updated — it is gated off by USE_NAPARI_FOR_LIVE_VIEW = False and never instantiated. A follow-up cleanup PR should remove the dead widget (notes left in worktrees/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 + ZOffset round-trip + zero-value persistence)
  • pytest software/tests/control/test_MultiPointController.py — added 2 tests (default apply_channel_offset + override)
  • Full suite: 1310 passed, 6 failed (zero new failures — all 6 are pre-existing on master)
  • black --config software/pyproject.toml --check software/ — clean
  • Manual simulation smoke (display required): launch python3 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 design
  • Hardware verification — capture offsets on a real microscope and confirm chromatic-shift correction works as expected

Design docs

  • Design spec: software/docs/laser-af-channel-offset-design.md (committed)
  • Implementation plan: software/docs/laser-af-channel-offset-plan.md (committed)

🤖 Generated with Claude Code

hongquanli and others added 28 commits May 23, 2026 17:12
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>
Alpaca233 and others added 11 commits May 25, 2026 17:53
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>

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 15 out of 15 changed files in this pull request and generated 2 comments.

Comment on lines +1242 to +1246
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code] Fixed in 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.

Comment on lines +4024 to +4028
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code] Fixed in 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.

Alpaca233 and others added 6 commits May 26, 2026 20:40
…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>
@Alpaca233
Alpaca233 marked this pull request as ready for review June 13, 2026 21:13
@Alpaca233
Alpaca233 requested review from Alpaca233 and Copilot June 13, 2026 21:13

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 15 out of 15 changed files in this pull request and generated 3 comments.

Comment on lines +12696 to +12700
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}"
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code] Fixed in 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.

Comment on lines +1284 to +1292
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}]")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code] Fixed in 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.

Comment on lines +415 to +419
# 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code] Fixed in 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')).

Alpaca233 and others added 2 commits June 13, 2026 14:36
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>
@Alpaca233
Alpaca233 merged commit 6919470 into master Jun 14, 2026
3 checks passed
Alpaca233 added a commit that referenced this pull request Jul 1, 2026
## 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>
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