Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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