feat: per-region laser-AF offset (focus-map constant-z + laser AF) - #562
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 |
…only Persisting offsets in the focus-points CSV coupled them to a laser-AF reference the file does not identify, so an import could silently apply offsets captured against a different reference plane (PR #562 review, finding 6). Rather than embedding a reference identity in the CSV, stop persisting offsets entirely: export writes the original four columns, import reads points only (an Offset_um column from earlier builds of this branch is ignored), and importing points drops any captured offsets, since they belong to the replaced points. Offsets are captured per session in the Focus Map tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Planning documents rot in-tree (both already cite line numbers earlier commits shifted); repo convention routes them to the AI-docs repo, where they now live under Squid/in-progress/. The user guide stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 (including CSV import). Offsets are session-only: the focus-points CSV stores points only, so offsets can never be applied against a laser-AF reference other than the one they were captured with. 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/per-region-laser-af-offset.md; the design spec + implementation plan live in the AI-docs repo (Squid/in-progress/).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 focus-points CSV parsing (points only; anOffset_umcolumn from earlier builds of this branch is ignored on import).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