feat: per-region laser-AF offset (focus-map constant-z + laser AF)#562
feat: per-region laser-AF offset (focus-map constant-z + laser AF)#562Alpaca233 wants to merge 15 commits into
Conversation
…z + laser AF) Capture a per-well laser-AF displacement offset at each focus point and drive laser AF to that per-region target during acquisition, instead of the single global reference plane. Active only when Reflection AF + Use Focus Map + the new mode checkbox are all on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ush) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Task 1 change made perform_autofocus read self.region_laser_af_offsets; the pre-existing _af_stub in test_MultiPointWorker_offsets.py did not define it, so two perform_autofocus tests regressed. The real worker sets it in __init__; this fixes only the stale test stub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…le logic Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fsets per run Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds support for per-region (well-level) laser reflection autofocus target offsets when using a constant-z focus map with “Fit by Region”, so each region can be maintained at its capture-time displacement relative to the global laser-AF reference plane.
Changes:
- Thread
region_laser_af_offsets: Dict[str, float]throughMultiPointController → AcquisitionParameters → MultiPointWorker, and use it as the laser-AF target inperform_autofocus. - Extend
FocusMapWidgetto capture/clear/sync per-region offsets, gate the UI with a new checkbox, and persist offsets via anOffset_umCSV column (back-compatible read). - Add unit tests covering backend apply path, capture edge cases, gating, invalidation, and CSV round-trip.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| software/control/core/multi_point_utils.py | Adds AcquisitionParameters.region_laser_af_offsets with default {}. |
| software/control/core/multi_point_controller.py | Stores/sets offsets, passes them into build_params, and clears controller state post-build to avoid stale reuse. |
| software/control/core/multi_point_worker.py | Uses per-region target offset in reflection AF path (move_to_target(target_um)). |
| software/control/widgets.py | Captures offsets in FocusMapWidget, adds UI gating + CSV persistence, and pushes offsets at acquisition start from both multipoint widgets. |
| software/control/gui_hcs.py | Passes the laser-AF controller into FocusMapWidget (supports None). |
| software/tests/control/test_per_region_laser_af_offset.py | New test suite for per-region offset behavior. |
| software/tests/control/test_MultiPointWorker_offsets.py | Updates AF stub to include region_laser_af_offsets for perform_autofocus. |
| software/docs/superpowers/specs/2026-06-18-per-region-laser-af-offset-design.md | Design spec documenting behavior and rationale. |
| software/docs/superpowers/plans/2026-06-18-per-region-laser-af-offset.md | Implementation plan and verification steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # FocusMapWidget is shared by both multipoint tabs; enable-state is "last toggle wins" — harmless because each acquisition-start re-reads its own checkbox_withReflectionAutofocus.isChecked(). | ||
| self.checkbox_withReflectionAutofocus.toggled.connect(self.focusMapWidget.set_reflection_af_available) |
| # FocusMapWidget is shared by both multipoint tabs; enable-state is "last toggle wins" — harmless because each acquisition-start re-reads its own checkbox_withReflectionAutofocus.isChecked(). | ||
| self.checkbox_withReflectionAutofocus.toggled.connect(self.focusMapWidget.set_reflection_af_available) |
- guard offset capture with isfinite (matches capture_current_z_offset) instead of isnan
- extract FocusMapWidget.get_offsets_for_acquisition() to DRY the gating across both
multipoint widgets (one source of truth for the reflection-AF + checkbox condition)
- _sync_offsets_to_focus_points via dict comprehension; dict(offsets or {}) in the setter
- only log the per-FOV laser-AF target when non-zero (no new noise on the default path)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…F (PR #562 review) Copilot review: the FocusMapWidget is shared by all three multipoint tabs, so wiring each tab's Reflection AF checkbox into a shared set_reflection_af_available was 'last toggle wins' — an inactive tab could disable/uncheck the per-region checkbox (clearing captured offsets) and the acquisition gate reads the shared checkbox, silently dropping the feature. - Enable the per-region checkbox purely on the shared focus-map controls (constant + Fit by Region); remove set_reflection_af_available and its per-tab wiring. The 'requires Reflection AF' rule is now enforced only at acquisition by get_offsets_for_acquisition, using the RUNNING tab's own checkbox. - Stop clearing captured offsets on uncheck (data-loss vector); offsets are cleared only on reference change or focus-point edits, and the gate returns {} while unchecked, so retaining them is safe. - Update tests accordingly. Also add docs/per-region-laser-af-offset.md (user instructions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n status line - Focus Map checkbox label 'Per-region laser AF offset' -> 'Laser AF Offset' (internal symbols unchanged). - On a successful in-range capture, the focus-map status line now shows the recorded offset (e.g. 'Region A1: Laser AF offset +2.30 µm') when Add/Update Z runs. - Doc + test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capturing a per-region offset calls measure_displacement(), which toggles the AF
laser over the microcontroller serial link and waits; a running main-camera live
stream queues triggers on the same link, contends, times out, and returns NaN
("spot not detected"). Mirror LiveControlWidget.capture_current_z_offset: stop the
main live around the measurement and restart it after (no signal, so the user is
not yanked to the Live tab). FocusMapWidget now receives the main liveController.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #562: per-region laser-AF offsetDate: 2026-07-05 OverviewThe PR lets each well/region be focused at its own offset from the global laser-AF reference plane: offsets are captured in the Focus Map tab (constant + Fit-by-Region + a new checkbox) as live Correctness findings (most severe first)
Refuted: the str-vs- Reclassified (not bugs):
Cleanup appendix (all verified against the branch)
Merge recommendationFix-then-merge — do not merge as-is. Finding 1 means the feature does not work for its intended use: any offset large enough to matter (≥ ~2–5 µm) is reverted by the spot-verification check on every FOV, and it also disables per-channel z-offsets for those FOVs as collateral. The good news: the recommended two-step fix (AF to reference, then open-loop relative offset — mirroring the existing per-channel offset pattern) is small, sidesteps the verification problem entirely instead of weakening the safety check, and keeps all captured data valid. Findings 2, 4, 5, 7, 8 are each small, localized fixes; finding 3 needs a modest signal redesign (distinct invalidation signal), and finding 6 wants at minimum an import-time status message + isfinite/range filter. Strategic context: the feature is worth landing — its capture flow is the natural authoring tool for the per-well offsets that the acquisition-method schema-v2 ( 🤖 Generated with Claude Code |
Independently verified all 8 findings; fixed all of them. F1 (BLOCKER): move_to_target(nonzero) always failed spot-alignment verification (its crop is fixed at x_reference) and reverted z at every FOV, and suppressed the per-channel offset via the af_succeeded gate. Now anchor closed-loop with move_to_target(0.0) (verification valid), then apply the offset as an OPEN-LOOP relative move (new LaserAutofocusController.apply_relative_offset_um). F2 (leak): offsets were pushed before the GUI disk/RAM checks and cleared only late in run_acquisition, so an abort stranded them on the shared controller for fluidics/TCP runs. Now run_acquisition consumes-and-clears up front (threaded into build_params), and both widgets push offsets only after all pre-flight checks pass. F3: benign objective/profile reload re-emits signal_reference_changed unchanged; the listener now clears only when x_reference actually differs from the one offsets were captured against. F4: edit_current_point now drops the region's (now-stale) offset and prompts re-capture. F5: capture failures now log (widget has a logger) and get_offsets_for_acquisition warns at run start about scan regions missing an offset. F6: CSV import only applies offsets when a reference is set, records the reference the offsets are tied to, and notes offsets in the status message. F7: whole import body re-wrapped so any failure shows a dialog (not a silent half-update). F8: _read_focus_points_csv rejects non-finite coordinates and offsets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review — I independently re-verified every finding against the branch (adversarial per-finding checks, including a numerical simulation of the spot-alignment collapse for F1) and they all hold. Fixed in F1 (blocker) — F2 (leak). F3. The F4. F5.
F6 / F7 / F8 (import). Import applies offsets only when a reference is set (recording the reference they're tied to) and notes them in the status message; the whole import body is re-wrapped so any failure surfaces a dialog instead of a silent half-update; and Two small corrections from verification: Not done here (per your split): the CSV reference-identity embed (F6's stronger form) and moving the planning docs out of the tree — I can spin those into follow-ups. Tests added for every fix (worker anchor+open-loop, build_params threading, reference-identity keep/clear, capture-failure logging, run-start missing-offset warning, non-finite CSV rejection). Note: a handful of |
|
Re-verified the fix commit (
With those three, this looks ready to merge from my side. 🤖 Generated with Claude Code |
Summary
Lets each well/region be focused at its own offset from the single global laser-AF reference plane when laser autofocus (Reflection AF) and a constant-z focus map are used together — instead of every well being driven to that one shared reference plane.
Workflow: set the laser-AF reference once, define one focus point per well (the existing focus-map constant + Fit by Region case), and at each well record z. At capture, the system stores that region's laser-AF displacement (
measure_displacement(), µm from the reference). During acquisition, laser AF at each FOV in a well drives to its stored offset (move_to_target(offset)) instead of the previously hardcodedmove_to_target(0). This pins each well to its capture-time relationship with the reference plane, so laser AF maintains per-well focus against drift across a time-lapse.Gated behind an explicit "Per-region laser AF offset" checkbox that is only active when Reflection AF is on and the focus map is
constant+Fit by Region. The focus map's absolute z stays as the coarse pre-position; the existing per-channel z-offset composes additively (both unchanged).What changed
control/core/multi_point_worker.py—perform_autofocustargetsregion_laser_af_offsets.get(region_id, 0.0)instead of0; new attribute unpacked from params.control/core/multi_point_controller.py—region_laser_af_offsetsfield +set_region_laser_af_offsets(); threaded throughbuild_params; cleared per-run afterbuild_paramsso stale offsets can't leak into a later acquisition from entry points that don't push them (fluidics / control server).control/core/multi_point_utils.py—AcquisitionParameters.region_laser_af_offsets: Dict[str, float](defaults{}→ current behavior).control/widgets.py(FocusMapWidget) — captures the offset on Add/Update-Z; the new checkbox + enable gating (Reflection AF + constant + Fit-by-Region); clears offsets when the laser-AF reference changes or the focus-point set changes; CSV export/import gains a back-compatibleOffset_umcolumn. Both multipoint widgets wire and push it at acquisition start (gated three ways); the third (fluidics) widget has no focus map and is untouched.control/gui_hcs.py— passes the laser-AF controller intoFocusMapWidget(toleratesNonewhen laser AF is unsupported).docs/superpowers/.Backward compatibility
Empty
region_laser_af_offsets(the default, and whenever the mode is off) reproduces the exact pre-feature behavior: every FOV targets displacement 0.move_to_target, the focus-map z-baking, and the per-channel z-offset logic are unchanged.Testing
tests/control/test_per_region_laser_af_offset.py(28): backend plumbing/apply, capture edge cases (no controller / no reference / NaN / out-of-range / disabled), reference-change invalidation, the constant-mode enable gating, and CSV round-trip + back-compat.black --checkclean.Not yet done
--simulationGUI smoke test is still pending (no display in the build env): live checkbox enable/disable, on-stage offset capture, reference-reset clearing, and CSV round-trip through the actual dialogs. The automated tests exercise the method logic via stubs but not the live Qt signal connections / acquisition-gate blocks.Out of scope (noted)
MultiPointController.focus_maphas the same pre-existing cross-entry-point staleness that the offsets had; only the newregion_laser_af_offsetsis reset per run here.🤖 Generated with Claude Code