Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f1ad58a
docs: design spec for per-region laser-AF offset (focus-map constant-…
Alpaca233 Jun 19, 2026
001d7e9
docs: implementation plan for per-region laser-AF offset
Alpaca233 Jun 19, 2026
2f24e0a
feat: apply per-region laser-AF target offset in multipoint worker
Alpaca233 Jun 19, 2026
8d046b3
feat: capture per-region laser-AF offset in FocusMapWidget
Alpaca233 Jun 19, 2026
dba0341
fix: move test import to top; cover laser-AF reference invalidation
Alpaca233 Jun 19, 2026
9b0f221
feat: persist per-region laser-AF offsets in focus-points CSV
Alpaca233 Jun 19, 2026
99098f6
feat: per-region laser-AF offset GUI wiring (checkbox + acquisition p…
Alpaca233 Jun 19, 2026
10b2475
fix: set region_laser_af_offsets on perform_autofocus test stub
Alpaca233 Jun 19, 2026
ef783d9
test: cover FocusMapWidget reflection-AF availability and offset-togg…
Alpaca233 Jun 19, 2026
dfcde42
fix: scope per-region AF offset to constant focus map; clear stale of…
Alpaca233 Jun 19, 2026
2511938
refactor: simplify per-region laser-AF offset code
Alpaca233 Jun 19, 2026
686dea3
fix: decouple per-region AF offset checkbox from per-tab Reflection A…
Alpaca233 Jun 19, 2026
172d305
feat: rename checkbox to 'Laser AF Offset' and show captured offset o…
Alpaca233 Jun 19, 2026
3c8fd25
fix: suspend main live during Update-Z laser-AF offset capture
Alpaca233 Jun 19, 2026
9d33d54
fix: address PR #562 review findings (per-region laser-AF offset)
Alpaca233 Jul 6, 2026
3981e56
refactor: drop the Offset_um column — per-region offsets are session-…
Alpaca233 Jul 20, 2026
c2a15fc
docs: move the plan + design docs to the AI-docs repo
Alpaca233 Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions software/control/core/laser_auto_focus_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,19 @@ def _move_z(self, um_to_move: float) -> None:
else:
self.stage.move_z(um_to_move / 1000)

def apply_relative_offset_um(self, offset_um: float) -> None:
"""Open-loop relative Z move of ``offset_um`` (displacement µm, 1:1 with Z), with NO
spot re-verification.

Intended to run right after ``move_to_target(0.0)`` has anchored — and verified — the
spot at the reference plane, to then place the sample at a target displacement from
that reference. Spot-alignment verification (``_verify_spot_alignment``) always crops
at ``x_reference`` and would fail for a deliberately-displaced spot, so it must NOT be
used to reach a nonzero target; this method deliberately skips it. No-op for offset 0.
"""
if offset_um:
self._move_z(offset_um)

def set_reference(self) -> bool:
"""Set the current spot position as the reference position.

Expand Down
23 changes: 21 additions & 2 deletions software/control/core/multi_point_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ def __init__(
self.overlap_percent = 10.0 # FOV overlap percentage

self.focus_map = None
self.region_laser_af_offsets = {}
self.gen_focus_map = False
self.focus_map_storage = []
self.already_using_fmap = False
Expand Down Expand Up @@ -415,6 +416,11 @@ def set_gen_focus_map_flag(self, flag):
def set_focus_map(self, focusMap):
self.focus_map = focusMap # None if dont use focusMap

def set_region_laser_af_offsets(self, offsets):
# region_id -> µm offset from the global laser-AF reference plane. Empty dict means
# every FOV targets the reference (displacement 0), i.e. current behavior.
self.region_laser_af_offsets = dict(offsets or {})

def set_base_path(self, path):
self.base_path = path

Expand Down Expand Up @@ -674,6 +680,13 @@ def get_estimated_mosaic_ram_bytes(self) -> int:
return mosaic_width * mosaic_height * bytes_per_pixel * num_channels

def run_acquisition(self, acquire_current_fov=False):
# Consume the per-region laser-AF offsets for THIS run and clear the sticky controller
# copy up-front. Any early return below — or a prior GUI abort that pushed offsets but
# never reached run_acquisition (e.g. aborted on the disk/RAM dialog) — then cannot leak
# them into a later acquisition from an entry point that never sets them (fluidics
# widget, TCP control server).
run_region_laser_af_offsets = self.region_laser_af_offsets
self.region_laser_af_offsets = {}
if not self.validate_acquisition_settings():
# emit acquisition finished signal to re-enable the UI
self.callbacks.signal_acquisition_finished()
Expand Down Expand Up @@ -840,7 +853,10 @@ def finish_fn():

updated_callbacks = dataclasses.replace(self.callbacks, signal_acquisition_finished=finish_fn)

acquisition_params = self.build_params(scan_position_information=scan_position_information)
acquisition_params = self.build_params(
scan_position_information=scan_position_information,
region_laser_af_offsets=run_region_laser_af_offsets,
)

# Gather objective and camera info for YAML
current_objective = self.objectiveStore.current_objective
Expand Down Expand Up @@ -918,7 +934,9 @@ def finish_fn():
self._memory_monitor.stop()
self._memory_monitor = None

def build_params(self, scan_position_information: ScanPositionInformation) -> AcquisitionParameters:
def build_params(
self, scan_position_information: ScanPositionInformation, region_laser_af_offsets: Optional[dict] = None
) -> AcquisitionParameters:
# Determine plate dimensions from wellplate format if available
plate_num_rows = 8 # Default for 96-well
plate_num_cols = 12
Expand Down Expand Up @@ -958,6 +976,7 @@ def build_params(self, scan_position_information: ScanPositionInformation) -> Ac
plate_num_rows=plate_num_rows,
plate_num_cols=plate_num_cols,
xy_mode=self.xy_mode,
region_laser_af_offsets=region_laser_af_offsets if region_laser_af_offsets is not None else {},
)

def _on_acquisition_completed(self):
Expand Down
7 changes: 6 additions & 1 deletion software/control/core/multi_point_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List, Tuple, Dict, Optional, Callable, TYPE_CHECKING

from control.core.job_processing import CaptureInfo
Expand Down Expand Up @@ -64,6 +64,11 @@ class AcquisitionParameters:
# XY mode for determining scan type
xy_mode: str = "Current Position" # "Current Position", "Select Wells", "Manual", "Load Coordinates"

# Per-region laser-AF target offsets (µm from the global laser-AF reference plane),
# keyed by region_id. Empty unless the focus-map + laser-AF combined mode is active,
# in which case each FOV in a region targets that region's offset instead of 0.
region_laser_af_offsets: Dict[str, float] = field(default_factory=dict)


@dataclass
class OverallProgressUpdate:
Expand Down
11 changes: 10 additions & 1 deletion software/control/core/multi_point_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def __init__(
self.do_reflection_af = acquisition_parameters.do_reflection_autofocus
self.use_piezo = acquisition_parameters.use_piezo
self.apply_channel_offset = acquisition_parameters.apply_channel_offset
self.region_laser_af_offsets = acquisition_parameters.region_laser_af_offsets
self.display_resolution_scaling = acquisition_parameters.display_resolution_scaling

self.experiment_ID = acquisition_parameters.experiment_ID
Expand Down Expand Up @@ -1155,7 +1156,15 @@ def perform_autofocus(self, region_id, fov):
# value, NOT by raising — both paths must mark the FOV's AF as failed or
# the per-channel z-offset gate would apply offsets from an unanchored z.
try:
af_succeeded = self.laser_auto_focus_controller.move_to_target(0)
target_um = self.region_laser_af_offsets.get(region_id, 0.0)
# Anchor closed-loop at the reference (displacement 0), where spot-alignment
# verification is valid, then apply the per-region offset as an OPEN-LOOP
# relative move. Driving move_to_target() straight to a nonzero displacement
# would always fail verification (its crop is fixed at x_reference) and revert.
af_succeeded = self.laser_auto_focus_controller.move_to_target(0.0)
if af_succeeded and target_um:
self._log.info(f"applying per-region laser AF offset for region '{region_id}': {target_um:+.2f} µm")
self.laser_auto_focus_controller.apply_relative_offset_um(target_um)
except Exception as e:
file_ID = f"{region_id}_focus_camera.bmp"
saving_path = os.path.join(self.base_path, self.experiment_ID, str(self.time_point), file_ID)
Expand Down
7 changes: 6 additions & 1 deletion software/control/gui_hcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,12 @@ def load_widgets(self):
self.wellSelectionWidget = widgets.Well1536SelectionWidget(self.wellplateFormatWidget)
self.scanCoordinates.add_well_selector(self.wellSelectionWidget)
self.focusMapWidget = widgets.FocusMapWidget(
self.stage, self.navigationViewer, self.scanCoordinates, core.FocusMap()
self.stage,
self.navigationViewer,
self.scanCoordinates,
core.FocusMap(),
self.laserAutofocusController,
self.liveController,
)

if SUPPORT_LASER_AUTOFOCUS:
Expand Down
Loading
Loading