From 012183c3fb2beef129abcf6c23506f74ae80a9b1 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 01:30:17 -0800 Subject: [PATCH 1/6] feat: Add "From Center" z-stack mode for centered acquisitions Adds a new z-stack acquisition mode that centers the z-stack around the current focus position, in addition to the existing "From Bottom" mode. Changes: - WellplateMultiPointWidget: Add "From Center" option to z-mode dropdown - FlexibleMultiPointWidget: Connect existing combobox_z_stack to controller - Both widgets: Calculate correct z_range for each mode (always [bottom, top]) - Worker: Remove redundant offset calculation in prepare_z_stack() - Fix: Sync z_stacking_config when loading from cache or YAML Design: z_range is always [bottom, top] in absolute coordinates. The UI calculates the correct range based on mode, and the worker just executes. For FROM_CENTER, the stage returns to the center (user's focus position) after acquisition completes. Co-Authored-By: Claude Opus 4.5 --- software/control/core/multi_point_worker.py | 6 +- software/control/widgets.py | 67 ++++++++++++++++++--- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/software/control/core/multi_point_worker.py b/software/control/core/multi_point_worker.py index 564f66395..cb2af3151 100644 --- a/software/control/core/multi_point_worker.py +++ b/software/control/core/multi_point_worker.py @@ -1282,10 +1282,8 @@ def perform_autofocus(self, region_id, fov): return True def prepare_z_stack(self): - # move to bottom of the z stack - if self.z_stacking_config == "FROM CENTER": - self.stage.move_z(-self.deltaZ * round((self.NZ - 1) / 2.0)) - self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000) + # UI already calculated correct z_range - just stabilize + # (initialize_z_stack already moved to z_range[0] which is the bottom) self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000) def handle_z_offset(self, config, not_offset): diff --git a/software/control/widgets.py b/software/control/widgets.py index b8b107dbe..2a5a9e802 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -5564,6 +5564,7 @@ def add_components(self): dz_half.addStretch(1) dz_half.addWidget(QLabel("Nz")) dz_half.addWidget(self.entry_NZ) + dz_half.addWidget(self.combobox_z_stack) dz_half.addSpacerItem(edge_spacer) dt_half = QHBoxLayout() @@ -5688,7 +5689,7 @@ def setup_connections(self): self.btn_startAcquisition.clicked.connect(self.toggle_acquisition) self.multipointController.acquisition_finished.connect(self.acquisition_is_finished) self.list_configurations.itemSelectionChanged.connect(self.emit_selected_channels) - # self.combobox_z_stack.currentIndexChanged.connect(self.signal_z_stacking.emit) + self.combobox_z_stack.currentIndexChanged.connect(self.multipointController.set_z_stacking_config) self.multipointController.signal_acquisition_progress.connect(self.update_acquisition_progress) self.multipointController.signal_region_progress.connect(self.update_region_progress) @@ -6014,9 +6015,24 @@ def toggle_acquisition(self, pressed): self.multipointController.set_z_range(minZ, maxZ) else: z = self.stage.get_pos().z_mm - dz = self.entry_deltaZ.value() + dz_mm = self.entry_deltaZ.value() / 1000 # Convert from μm to mm Nz = self.entry_NZ.value() - self.multipointController.set_z_range(z, z + dz / 1000 * (Nz - 1)) + total_z_travel = dz_mm * (Nz - 1) + + z_stack_mode = self.combobox_z_stack.currentIndex() + if z_stack_mode == 1: # From Center + # z_range[0] = bottom (start), z_range[1] = top (end) + # Z-stack centered around current position + half_range = total_z_travel / 2 + self.multipointController.set_z_range(z - half_range, z + half_range) + elif z_stack_mode == 2: # From Top (Z-max) + # z_range[0] = bottom (end), z_range[1] = top (start) + # Current position is top, acquisition goes down + self.multipointController.set_z_range(z - total_z_travel, z) + else: # From Bottom (Z-min) - default + # z_range[0] = bottom (start), z_range[1] = top (end) + # Current position is bottom + self.multipointController.set_z_range(z, z + total_z_travel) if self.checkbox_useFocusMap.isChecked(): self.focusMapWidget.fit_surface() @@ -6580,6 +6596,7 @@ def _apply_yaml_settings(self, yaml_data): self.checkbox_withAutofocus, self.checkbox_withReflectionAutofocus, self.checkbox_usePiezo, + self.combobox_z_stack, ] # Add optional widgets if they exist @@ -6607,6 +6624,11 @@ def _apply_yaml_settings(self, yaml_data): self.entry_NZ.setValue(yaml_data.nz) self.entry_deltaZ.setValue(yaml_data.delta_z_um) + # Z-stacking mode - map YAML config to combobox index + z_stack_index_map = {"FROM BOTTOM": 0, "FROM CENTER": 1, "FROM TOP": 2} + z_stack_index = z_stack_index_map.get(yaml_data.z_stacking_config, 0) + self.combobox_z_stack.setCurrentIndex(z_stack_index) + # Piezo setting self.checkbox_usePiezo.setChecked(yaml_data.use_piezo) @@ -6635,6 +6657,9 @@ def _apply_yaml_settings(self, yaml_data): for widget in widgets_to_block: widget.blockSignals(False) + # Sync z_stacking_config with loaded z_stack mode (signals were blocked during load) + self.multipointController.set_z_stacking_config(self.combobox_z_stack.currentIndex()) + # Update FOV positions to reflect new NX, NY, delta values self.update_fov_positions() @@ -6984,7 +7009,7 @@ def add_components(self): self.checkbox_z.setChecked(False) self.combobox_z_mode = QComboBox() - self.combobox_z_mode.addItems(["From Bottom", "Set Range"]) + self.combobox_z_mode.addItems(["From Bottom", "From Center", "Set Range"]) self.combobox_z_mode.setEnabled(False) # Initially disabled since Z is unchecked z_layout = QHBoxLayout() @@ -7363,7 +7388,7 @@ def load_multipoint_widget_config_from_cache(self): self.checkbox_z.setChecked(settings.get("z_enabled", False)) z_mode = settings.get("z_mode", "From Bottom") - if z_mode in ["From Bottom", "Set Range"]: + if z_mode in ["From Bottom", "From Center", "Set Range"]: self.combobox_z_mode.setCurrentText(z_mode) self.checkbox_time.setChecked(settings.get("time_enabled", False)) @@ -7417,6 +7442,9 @@ def load_multipoint_widget_config_from_cache(self): if self.combobox_z_mode.currentText() == "Set Range": self.toggle_z_range_controls(True) + # Sync z_stacking_config with loaded z_mode (signals were blocked during load) + self.on_z_mode_changed(self.combobox_z_mode.currentText()) + # Ensure Time controls are properly shown based on loaded Time state if self.checkbox_time.isChecked(): self.show_time_controls(True) @@ -7737,6 +7765,13 @@ def on_z_mode_changed(self, mode): """Handle Z mode dropdown change""" # Show/hide Z-min/Z-max controls based on mode self.toggle_z_range_controls(mode == "Set Range") + + # Set the z-stacking configuration in the controller + # Map combobox text to Z_STACKING_CONFIG_MAP index + z_config_map = {"From Bottom": 0, "From Center": 1, "Set Range": 0} # Set Range uses From Bottom config + config_index = z_config_map.get(mode, 0) + self.multipointController.set_z_stacking_config(config_index) + self._log.debug(f"Z mode changed to: {mode}") def on_time_toggled(self, checked): @@ -8342,7 +8377,8 @@ def toggle_acquisition(self, pressed): self.scanCoordinates.sort_coordinates() - if self.combobox_z_mode.currentText() == "Set Range": + z_mode = self.combobox_z_mode.currentText() + if z_mode == "Set Range": # Set Z-range (convert from μm to mm) minZ = self.entry_minZ.value() / 1000 # Convert from μm to mm maxZ = self.entry_maxZ.value() / 1000 # Convert from μm to mm @@ -8350,9 +8386,20 @@ def toggle_acquisition(self, pressed): self._log.debug(f"Set z-range: ({minZ}, {maxZ})") else: z = self.stage.get_pos().z_mm - dz = self.entry_deltaZ.value() + dz_mm = self.entry_deltaZ.value() / 1000 # Convert from μm to mm Nz = self.entry_NZ.value() - self.multipointController.set_z_range(z, z + dz * (Nz - 1)) + total_z_travel = dz_mm * (Nz - 1) + + if z_mode == "From Center": + # z_range[0] = bottom (start), z_range[1] = top (end) + # Z-stack centered around current position + half_range = total_z_travel / 2 + self.multipointController.set_z_range(z - half_range, z + half_range) + self._log.debug(f"Set z-range (from center): ({z - half_range}, {z + half_range})") + else: + # From Bottom: z-stack starts at current position going up + self.multipointController.set_z_range(z, z + total_z_travel) + self._log.debug(f"Set z-range (from bottom): ({z}, {z + total_z_travel})") if self.checkbox_useFocusMap.isChecked(): # Try to fit the surface @@ -8782,6 +8829,7 @@ def _apply_yaml_settings(self, yaml_data): # Z mode - map YAML config to combobox text z_mode_map = { "FROM BOTTOM": "From Bottom", + "FROM CENTER": "From Center", "SET RANGE": "Set Range", } z_mode = z_mode_map.get(yaml_data.z_stacking_config, "From Bottom") @@ -8844,6 +8892,9 @@ def _apply_yaml_settings(self, yaml_data): self.update_tab_styles() self.update_coordinates() + # Sync z_stacking_config with loaded z_mode (signals were blocked during load) + self.on_z_mode_changed(self.combobox_z_mode.currentText()) + def _load_well_regions(self, regions): """Load well regions from YAML and select them in the well selector.""" if not self.well_selection_widget: From 3148be88900bfcb48d345706d516640cfc9151fd Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 01:44:31 -0800 Subject: [PATCH 2/6] feat: Disable autofocus options based on z-stack mode Autofocus options are now enabled/disabled based on z-stack mode: - Contrast AF: only enabled for "From Center" (focus plane is at center) - Laser AF: only enabled for "From Bottom" (can find surface first) Both AF options are disabled for "From Top" and "Set Range" modes. Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 50 ++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index 2a5a9e802..c158d8294 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -5690,6 +5690,7 @@ def setup_connections(self): self.multipointController.acquisition_finished.connect(self.acquisition_is_finished) self.list_configurations.itemSelectionChanged.connect(self.emit_selected_channels) self.combobox_z_stack.currentIndexChanged.connect(self.multipointController.set_z_stacking_config) + self.combobox_z_stack.currentIndexChanged.connect(self.on_z_stack_mode_changed) self.multipointController.signal_acquisition_progress.connect(self.update_acquisition_progress) self.multipointController.signal_region_progress.connect(self.update_region_progress) @@ -5716,6 +5717,8 @@ def setup_connections(self): self.toggle_z_range_controls(False) self.multipointController.set_use_piezo(self.checkbox_usePiezo.isChecked()) + # Initialize AF checkbox states based on default z-stack mode + self.on_z_stack_mode_changed(self.combobox_z_stack.currentIndex()) def setup_layout(self): self.grid = QVBoxLayout() @@ -5727,6 +5730,30 @@ def setup_layout(self): self.grid.addLayout(self.row_progress_layout) self.setLayout(self.grid) + def on_z_stack_mode_changed(self, index): + """Handle z-stack mode dropdown change - update autofocus checkbox states. + + Args: + index: 0=From Bottom, 1=From Center, 2=From Top + """ + # Update autofocus checkbox states based on z-stack mode + # - Contrast AF: only enabled for "From Center" (focus plane is at center) + # - Laser AF: only enabled for "From Bottom" (can find surface before z-stack) + contrast_af_allowed = index == 1 # From Center + laser_af_allowed = index == 0 # From Bottom + + self.checkbox_withAutofocus.setEnabled(contrast_af_allowed) + self.checkbox_withReflectionAutofocus.setEnabled(laser_af_allowed) + + # Uncheck if disabled + if not contrast_af_allowed and self.checkbox_withAutofocus.isChecked(): + self.checkbox_withAutofocus.setChecked(False) + if not laser_af_allowed and self.checkbox_withReflectionAutofocus.isChecked(): + self.checkbox_withReflectionAutofocus.setChecked(False) + + mode_names = ["From Bottom", "From Center", "From Top"] + self._log.debug(f"Z-stack mode changed to: {mode_names[index]}") + def toggle_z_range_controls(self, state): is_visible = bool(state) @@ -5739,8 +5766,6 @@ def toggle_z_range_controls(self, state): if widget is not None: widget.setVisible(is_visible) - # Disable reflection autofocus checkbox if Z-range is visible - self.checkbox_withReflectionAutofocus.setEnabled(not is_visible) # Enable/disable NZ entry based on the inverse of is_visible self.entry_NZ.setEnabled(not is_visible) current_z = self.stage.get_pos().z_mm * 1000 @@ -6657,8 +6682,9 @@ def _apply_yaml_settings(self, yaml_data): for widget in widgets_to_block: widget.blockSignals(False) - # Sync z_stacking_config with loaded z_stack mode (signals were blocked during load) + # Sync z_stacking_config and AF states with loaded z_stack mode (signals were blocked during load) self.multipointController.set_z_stacking_config(self.combobox_z_stack.currentIndex()) + self.on_z_stack_mode_changed(self.combobox_z_stack.currentIndex()) # Update FOV positions to reflect new NX, NY, delta values self.update_fov_positions() @@ -7287,6 +7313,9 @@ def add_components(self): # Load cached acquisition settings self.load_multipoint_widget_config_from_cache() + # Initialize AF checkbox states based on current z-mode (in case no cache exists) + self.on_z_mode_changed(self.combobox_z_mode.currentText()) + # Connect settings saving to relevant value changes self.checkbox_xy.toggled.connect(self.save_multipoint_widget_config_to_cache) self.combobox_xy_mode.currentTextChanged.connect(self.save_multipoint_widget_config_to_cache) @@ -7772,6 +7801,21 @@ def on_z_mode_changed(self, mode): config_index = z_config_map.get(mode, 0) self.multipointController.set_z_stacking_config(config_index) + # Update autofocus checkbox states based on z-stack mode + # - Contrast AF: only enabled for "From Center" (focus plane is at center) + # - Laser AF: only enabled for "From Bottom" (can find surface before z-stack) + contrast_af_allowed = mode == "From Center" + laser_af_allowed = mode == "From Bottom" + + self.checkbox_withAutofocus.setEnabled(contrast_af_allowed) + self.checkbox_withReflectionAutofocus.setEnabled(laser_af_allowed) + + # Uncheck if disabled + if not contrast_af_allowed and self.checkbox_withAutofocus.isChecked(): + self.checkbox_withAutofocus.setChecked(False) + if not laser_af_allowed and self.checkbox_withReflectionAutofocus.isChecked(): + self.checkbox_withReflectionAutofocus.setChecked(False) + self._log.debug(f"Z mode changed to: {mode}") def on_time_toggled(self, checked): From 3138f0f5e90857d962f174e8cae17da3683399c0 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 01:58:56 -0800 Subject: [PATCH 3/6] fix: Add warnings for unsupported z-mode and AF setting conflicts - Warn when YAML has z_stacking_config='FROM TOP' in wellplate mode (not supported, falls back to 'From Bottom') - Warn when AF settings from YAML are disabled due to z-mode restrictions (e.g., contrast AF disabled when z-mode is not 'From Center') Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/software/control/widgets.py b/software/control/widgets.py index c158d8294..9e6ea26e4 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -6686,6 +6686,15 @@ def _apply_yaml_settings(self, yaml_data): self.multipointController.set_z_stacking_config(self.combobox_z_stack.currentIndex()) self.on_z_stack_mode_changed(self.combobox_z_stack.currentIndex()) + # Warn if AF settings were modified due to z-mode restrictions + af_warnings = [] + if yaml_data.contrast_af and not self.checkbox_withAutofocus.isChecked(): + af_warnings.append("Contrast AF was disabled (only allowed for 'From Center' mode)") + if yaml_data.laser_af and not self.checkbox_withReflectionAutofocus.isChecked(): + af_warnings.append("Laser AF was disabled (only allowed for 'From Bottom' mode)") + if af_warnings: + self._log.warning(f"YAML autofocus settings modified: {'; '.join(af_warnings)}") + # Update FOV positions to reflect new NX, NY, delta values self.update_fov_positions() @@ -8877,6 +8886,11 @@ def _apply_yaml_settings(self, yaml_data): "SET RANGE": "Set Range", } z_mode = z_mode_map.get(yaml_data.z_stacking_config, "From Bottom") + if yaml_data.z_stacking_config == "FROM TOP": + self._log.warning( + f"YAML has z_stacking_config='FROM TOP' which is not supported in wellplate mode. " + f"Using 'From Bottom' instead." + ) self.combobox_z_mode.setCurrentText(z_mode) # Piezo setting @@ -8939,6 +8953,15 @@ def _apply_yaml_settings(self, yaml_data): # Sync z_stacking_config with loaded z_mode (signals were blocked during load) self.on_z_mode_changed(self.combobox_z_mode.currentText()) + # Warn if AF settings were modified due to z-mode restrictions + af_warnings = [] + if yaml_data.contrast_af and not self.checkbox_withAutofocus.isChecked(): + af_warnings.append("Contrast AF was disabled (only allowed for 'From Center' mode)") + if yaml_data.laser_af and not self.checkbox_withReflectionAutofocus.isChecked(): + af_warnings.append("Laser AF was disabled (only allowed for 'From Bottom' mode)") + if af_warnings: + self._log.warning(f"YAML autofocus settings modified: {'; '.join(af_warnings)}") + def _load_well_regions(self, regions): """Load well regions from YAML and select them in the well selector.""" if not self.well_selection_widget: From 26b800075495509ecf6e9445d29355e6e7953373 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 02:29:35 -0800 Subject: [PATCH 4/6] refactor: Add ZStackMode enum and extract shared z-stack helpers - Add ZStackMode IntEnum with FROM_BOTTOM, FROM_CENTER, FROM_TOP, SET_RANGE - Enum properties: allows_contrast_af, allows_laser_af, worker_config_index - Extract shared helpers: calculate_z_range(), update_autofocus_checkboxes(), log_af_restriction_warnings() - Fix conflicting AF checkbox management in WellplateMultiPointWidget - Fix silent failures: add validation and logging for invalid z-modes - Replace print() with proper logging in set_z_stacking_config() Co-Authored-By: Claude Opus 4.5 --- .../control/core/multi_point_controller.py | 8 +- software/control/core/multi_point_worker.py | 3 +- software/control/widgets.py | 293 ++++++++++++------ 3 files changed, 208 insertions(+), 96 deletions(-) diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index f9051eb73..30a3c3ac8 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -306,7 +306,13 @@ def set_use_piezo(self, checked): def set_z_stacking_config(self, z_stacking_config_index): if z_stacking_config_index in control._def.Z_STACKING_CONFIG_MAP: self.z_stacking_config = control._def.Z_STACKING_CONFIG_MAP[z_stacking_config_index] - print(f"z-stacking configuration set to {self.z_stacking_config}") + self._log.debug(f"z-stacking configuration set to {self.z_stacking_config}") + else: + self._log.warning( + f"Invalid z_stacking_config_index: {z_stacking_config_index}. " + f"Valid indices: {list(control._def.Z_STACKING_CONFIG_MAP.keys())}. " + f"Keeping current config: {self.z_stacking_config}" + ) def set_z_range(self, minZ, maxZ): self.z_range = [minZ, maxZ] diff --git a/software/control/core/multi_point_worker.py b/software/control/core/multi_point_worker.py index cb2af3151..9d02df793 100644 --- a/software/control/core/multi_point_worker.py +++ b/software/control/core/multi_point_worker.py @@ -1282,8 +1282,7 @@ def perform_autofocus(self, region_id, fov): return True def prepare_z_stack(self): - # UI already calculated correct z_range - just stabilize - # (initialize_z_stack already moved to z_range[0] which is the bottom) + # Allow stage to stabilize after moving to z_range start position self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000) def handle_z_offset(self, config, not_offset): diff --git a/software/control/widgets.py b/software/control/widgets.py index 9e6ea26e4..e6b36c842 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -5,6 +5,7 @@ import yaml import logging import sys +from enum import IntEnum from pathlib import Path from typing import Dict, List, Optional, TYPE_CHECKING @@ -151,6 +152,117 @@ def save_last_used_saving_path(path: str) -> None: pass # Silently fail - caching is a convenience feature +# ----------------------------------------------------------------------------- +# Z-stack mode helpers (shared between FlexibleMultiPointWidget and WellplateMultiPointWidget) +# ----------------------------------------------------------------------------- + + +class ZStackMode(IntEnum): + """Z-stack acquisition modes. + + Values 0-2 match Z_STACKING_CONFIG_MAP in _def.py for worker configuration. + SET_RANGE is UI-only (uses FROM_BOTTOM for worker, but different AF behavior). + """ + + FROM_BOTTOM = 0 # Start at current Z, go up + FROM_CENTER = 1 # Center around current Z + FROM_TOP = 2 # Start at current Z, go down + SET_RANGE = 3 # User specifies absolute Z min/max (UI-only) + + @property + def allows_contrast_af(self) -> bool: + """Contrast AF only works when focus plane is at center of z-stack.""" + return self == ZStackMode.FROM_CENTER + + @property + def allows_laser_af(self) -> bool: + """Laser AF only works when starting from bottom (can find surface first).""" + return self == ZStackMode.FROM_BOTTOM + + @property + def worker_config_index(self) -> int: + """Get the index for Z_STACKING_CONFIG_MAP (SET_RANGE uses FROM_BOTTOM).""" + if self == ZStackMode.SET_RANGE: + return ZStackMode.FROM_BOTTOM.value + return self.value + + +def calculate_z_range(current_z_mm: float, dz_um: float, nz: int, mode: ZStackMode) -> tuple: + """Calculate z-range based on current position and z-stack mode. + + Args: + current_z_mm: Current stage Z position in mm + dz_um: Step size in micrometers + nz: Number of z-slices + mode: Z-stack mode (FROM_BOTTOM, FROM_CENTER, or FROM_TOP) + + Returns: + Tuple of (min_z_mm, max_z_mm) for set_z_range() + + Note: + SET_RANGE mode should not use this function - the user specifies Z min/max directly. + """ + dz_mm = dz_um / 1000 + total_z_travel = dz_mm * (nz - 1) + + if mode == ZStackMode.FROM_CENTER: + half_range = total_z_travel / 2 + return (current_z_mm - half_range, current_z_mm + half_range) + elif mode == ZStackMode.FROM_TOP: + return (current_z_mm - total_z_travel, current_z_mm) + else: # FROM_BOTTOM (default) + return (current_z_mm, current_z_mm + total_z_travel) + + +def update_autofocus_checkboxes( + contrast_af_allowed: bool, + laser_af_allowed: bool, + contrast_af_checkbox: QCheckBox, + laser_af_checkbox: QCheckBox, +) -> None: + """Update autofocus checkbox enabled states. + + Args: + contrast_af_allowed: Whether contrast AF should be enabled + laser_af_allowed: Whether laser AF should be enabled + contrast_af_checkbox: The contrast autofocus checkbox + laser_af_checkbox: The laser/reflection autofocus checkbox + """ + contrast_af_checkbox.setEnabled(contrast_af_allowed) + laser_af_checkbox.setEnabled(laser_af_allowed) + + # Uncheck if disabled + if not contrast_af_allowed and contrast_af_checkbox.isChecked(): + contrast_af_checkbox.setChecked(False) + if not laser_af_allowed and laser_af_checkbox.isChecked(): + laser_af_checkbox.setChecked(False) + + +def log_af_restriction_warnings( + yaml_contrast_af: bool, + yaml_laser_af: bool, + actual_contrast_af: bool, + actual_laser_af: bool, + log: logging.Logger, +) -> None: + """Log warnings if autofocus settings were modified due to z-mode restrictions. + + Args: + yaml_contrast_af: Contrast AF setting from YAML + yaml_laser_af: Laser AF setting from YAML + actual_contrast_af: Actual contrast AF checkbox state after z-mode restrictions + actual_laser_af: Actual laser AF checkbox state after z-mode restrictions + log: Logger instance + """ + warnings = [] + if yaml_contrast_af and not actual_contrast_af: + warnings.append("Contrast AF was disabled (only allowed for 'From Center' mode)") + if yaml_laser_af and not actual_laser_af: + warnings.append("Laser AF was disabled (only allowed for 'From Bottom' mode)") + if warnings: + log.warning(f"YAML autofocus settings modified: {'; '.join(warnings)}") + + class WrapperWindow(QMainWindow): def __init__(self, content_widget, *args, **kwargs): super().__init__(*args, **kwargs) @@ -5736,23 +5848,19 @@ def on_z_stack_mode_changed(self, index): Args: index: 0=From Bottom, 1=From Center, 2=From Top """ - # Update autofocus checkbox states based on z-stack mode - # - Contrast AF: only enabled for "From Center" (focus plane is at center) - # - Laser AF: only enabled for "From Bottom" (can find surface before z-stack) - contrast_af_allowed = index == 1 # From Center - laser_af_allowed = index == 0 # From Bottom - - self.checkbox_withAutofocus.setEnabled(contrast_af_allowed) - self.checkbox_withReflectionAutofocus.setEnabled(laser_af_allowed) - - # Uncheck if disabled - if not contrast_af_allowed and self.checkbox_withAutofocus.isChecked(): - self.checkbox_withAutofocus.setChecked(False) - if not laser_af_allowed and self.checkbox_withReflectionAutofocus.isChecked(): - self.checkbox_withReflectionAutofocus.setChecked(False) - - mode_names = ["From Bottom", "From Center", "From Top"] - self._log.debug(f"Z-stack mode changed to: {mode_names[index]}") + try: + mode = ZStackMode(index) + except ValueError: + self._log.error(f"Invalid z-stack mode index: {index}. Using default (From Bottom).") + mode = ZStackMode.FROM_BOTTOM + + update_autofocus_checkboxes( + contrast_af_allowed=mode.allows_contrast_af, + laser_af_allowed=mode.allows_laser_af, + contrast_af_checkbox=self.checkbox_withAutofocus, + laser_af_checkbox=self.checkbox_withReflectionAutofocus, + ) + self._log.debug(f"Z-stack mode changed to: {mode.name}") def toggle_z_range_controls(self, state): is_visible = bool(state) @@ -6040,24 +6148,14 @@ def toggle_acquisition(self, pressed): self.multipointController.set_z_range(minZ, maxZ) else: z = self.stage.get_pos().z_mm - dz_mm = self.entry_deltaZ.value() / 1000 # Convert from μm to mm - Nz = self.entry_NZ.value() - total_z_travel = dz_mm * (Nz - 1) - - z_stack_mode = self.combobox_z_stack.currentIndex() - if z_stack_mode == 1: # From Center - # z_range[0] = bottom (start), z_range[1] = top (end) - # Z-stack centered around current position - half_range = total_z_travel / 2 - self.multipointController.set_z_range(z - half_range, z + half_range) - elif z_stack_mode == 2: # From Top (Z-max) - # z_range[0] = bottom (end), z_range[1] = top (start) - # Current position is top, acquisition goes down - self.multipointController.set_z_range(z - total_z_travel, z) - else: # From Bottom (Z-min) - default - # z_range[0] = bottom (start), z_range[1] = top (end) - # Current position is bottom - self.multipointController.set_z_range(z, z + total_z_travel) + mode = ZStackMode(self.combobox_z_stack.currentIndex()) + z_range = calculate_z_range( + z, + self.entry_deltaZ.value(), + self.entry_NZ.value(), + mode, + ) + self.multipointController.set_z_range(*z_range) if self.checkbox_useFocusMap.isChecked(): self.focusMapWidget.fit_surface() @@ -6650,9 +6748,19 @@ def _apply_yaml_settings(self, yaml_data): self.entry_deltaZ.setValue(yaml_data.delta_z_um) # Z-stacking mode - map YAML config to combobox index - z_stack_index_map = {"FROM BOTTOM": 0, "FROM CENTER": 1, "FROM TOP": 2} - z_stack_index = z_stack_index_map.get(yaml_data.z_stacking_config, 0) - self.combobox_z_stack.setCurrentIndex(z_stack_index) + z_stack_mode_map = { + "FROM BOTTOM": ZStackMode.FROM_BOTTOM, + "FROM CENTER": ZStackMode.FROM_CENTER, + "FROM TOP": ZStackMode.FROM_TOP, + } + z_stack_mode = z_stack_mode_map.get(yaml_data.z_stacking_config) + if z_stack_mode is None: + self._log.warning( + f"Unknown z_stacking_config in YAML: '{yaml_data.z_stacking_config}'. " + f"Valid values: {list(z_stack_mode_map.keys())}. Using 'FROM BOTTOM'." + ) + z_stack_mode = ZStackMode.FROM_BOTTOM + self.combobox_z_stack.setCurrentIndex(z_stack_mode.value) # Piezo setting self.checkbox_usePiezo.setChecked(yaml_data.use_piezo) @@ -6687,13 +6795,13 @@ def _apply_yaml_settings(self, yaml_data): self.on_z_stack_mode_changed(self.combobox_z_stack.currentIndex()) # Warn if AF settings were modified due to z-mode restrictions - af_warnings = [] - if yaml_data.contrast_af and not self.checkbox_withAutofocus.isChecked(): - af_warnings.append("Contrast AF was disabled (only allowed for 'From Center' mode)") - if yaml_data.laser_af and not self.checkbox_withReflectionAutofocus.isChecked(): - af_warnings.append("Laser AF was disabled (only allowed for 'From Bottom' mode)") - if af_warnings: - self._log.warning(f"YAML autofocus settings modified: {'; '.join(af_warnings)}") + log_af_restriction_warnings( + yaml_data.contrast_af, + yaml_data.laser_af, + self.checkbox_withAutofocus.isChecked(), + self.checkbox_withReflectionAutofocus.isChecked(), + self._log, + ) # Update FOV positions to reflect new NX, NY, delta values self.update_fov_positions() @@ -7426,8 +7534,11 @@ def load_multipoint_widget_config_from_cache(self): self.checkbox_z.setChecked(settings.get("z_enabled", False)) z_mode = settings.get("z_mode", "From Bottom") - if z_mode in ["From Bottom", "From Center", "Set Range"]: + valid_z_modes = ["From Bottom", "From Center", "Set Range"] + if z_mode in valid_z_modes: self.combobox_z_mode.setCurrentText(z_mode) + else: + self._log.warning(f"Invalid z_mode in cache: '{z_mode}'. Valid values: {valid_z_modes}. Using default.") self.checkbox_time.setChecked(settings.get("time_enabled", False)) self.entry_overlap.setValue(settings.get("fov_overlap", 10)) @@ -7799,33 +7910,35 @@ def on_z_toggled(self, checked): self._log.debug(f"Z acquisition {'enabled' if checked else 'disabled'}") - def on_z_mode_changed(self, mode): + def on_z_mode_changed(self, mode_text): """Handle Z mode dropdown change""" + # Map UI text to ZStackMode enum + mode_map = { + "From Bottom": ZStackMode.FROM_BOTTOM, + "From Center": ZStackMode.FROM_CENTER, + "Set Range": ZStackMode.SET_RANGE, + } + mode = mode_map.get(mode_text) + if mode is None: + self._log.error( + f"Invalid z-mode: '{mode_text}'. Valid modes: {list(mode_map.keys())}. Using 'From Bottom'." + ) + mode = ZStackMode.FROM_BOTTOM + # Show/hide Z-min/Z-max controls based on mode - self.toggle_z_range_controls(mode == "Set Range") + self.toggle_z_range_controls(mode == ZStackMode.SET_RANGE) - # Set the z-stacking configuration in the controller - # Map combobox text to Z_STACKING_CONFIG_MAP index - z_config_map = {"From Bottom": 0, "From Center": 1, "Set Range": 0} # Set Range uses From Bottom config - config_index = z_config_map.get(mode, 0) - self.multipointController.set_z_stacking_config(config_index) + # Set the z-stacking configuration in the controller (SET_RANGE uses FROM_BOTTOM for worker) + self.multipointController.set_z_stacking_config(mode.worker_config_index) # Update autofocus checkbox states based on z-stack mode - # - Contrast AF: only enabled for "From Center" (focus plane is at center) - # - Laser AF: only enabled for "From Bottom" (can find surface before z-stack) - contrast_af_allowed = mode == "From Center" - laser_af_allowed = mode == "From Bottom" - - self.checkbox_withAutofocus.setEnabled(contrast_af_allowed) - self.checkbox_withReflectionAutofocus.setEnabled(laser_af_allowed) - - # Uncheck if disabled - if not contrast_af_allowed and self.checkbox_withAutofocus.isChecked(): - self.checkbox_withAutofocus.setChecked(False) - if not laser_af_allowed and self.checkbox_withReflectionAutofocus.isChecked(): - self.checkbox_withReflectionAutofocus.setChecked(False) - - self._log.debug(f"Z mode changed to: {mode}") + update_autofocus_checkboxes( + contrast_af_allowed=mode.allows_contrast_af, + laser_af_allowed=mode.allows_laser_af, + contrast_af_checkbox=self.checkbox_withAutofocus, + laser_af_checkbox=self.checkbox_withReflectionAutofocus, + ) + self._log.debug(f"Z mode changed to: {mode.name}") def on_time_toggled(self, checked): """Handle Time checkbox toggle""" @@ -8107,10 +8220,9 @@ def toggle_z_range_controls(self, is_visible): if widget: widget.setVisible(is_visible) - # Disable and uncheck reflection autofocus checkbox if Z-range is visible - if is_visible: - self.checkbox_withReflectionAutofocus.setChecked(False) - self.checkbox_withReflectionAutofocus.setEnabled(not is_visible) + # Note: Autofocus checkbox states are now managed by on_z_mode_changed() + # via update_autofocus_checkboxes_for_z_mode() + # Enable/disable NZ entry based on the inverse of is_visible self.entry_NZ.setEnabled(not is_visible) current_z = self.stage.get_pos().z_mm * 1000 @@ -8439,20 +8551,15 @@ def toggle_acquisition(self, pressed): self._log.debug(f"Set z-range: ({minZ}, {maxZ})") else: z = self.stage.get_pos().z_mm - dz_mm = self.entry_deltaZ.value() / 1000 # Convert from μm to mm - Nz = self.entry_NZ.value() - total_z_travel = dz_mm * (Nz - 1) - - if z_mode == "From Center": - # z_range[0] = bottom (start), z_range[1] = top (end) - # Z-stack centered around current position - half_range = total_z_travel / 2 - self.multipointController.set_z_range(z - half_range, z + half_range) - self._log.debug(f"Set z-range (from center): ({z - half_range}, {z + half_range})") - else: - # From Bottom: z-stack starts at current position going up - self.multipointController.set_z_range(z, z + total_z_travel) - self._log.debug(f"Set z-range (from bottom): ({z}, {z + total_z_travel})") + mode = ZStackMode.FROM_CENTER if z_mode == "From Center" else ZStackMode.FROM_BOTTOM + z_range = calculate_z_range( + z, + self.entry_deltaZ.value(), + self.entry_NZ.value(), + mode, + ) + self.multipointController.set_z_range(*z_range) + self._log.debug(f"Set z-range ({mode.name}): {z_range}") if self.checkbox_useFocusMap.isChecked(): # Try to fit the surface @@ -8954,13 +9061,13 @@ def _apply_yaml_settings(self, yaml_data): self.on_z_mode_changed(self.combobox_z_mode.currentText()) # Warn if AF settings were modified due to z-mode restrictions - af_warnings = [] - if yaml_data.contrast_af and not self.checkbox_withAutofocus.isChecked(): - af_warnings.append("Contrast AF was disabled (only allowed for 'From Center' mode)") - if yaml_data.laser_af and not self.checkbox_withReflectionAutofocus.isChecked(): - af_warnings.append("Laser AF was disabled (only allowed for 'From Bottom' mode)") - if af_warnings: - self._log.warning(f"YAML autofocus settings modified: {'; '.join(af_warnings)}") + log_af_restriction_warnings( + yaml_data.contrast_af, + yaml_data.laser_af, + self.checkbox_withAutofocus.isChecked(), + self.checkbox_withReflectionAutofocus.isChecked(), + self._log, + ) def _load_well_regions(self, regions): """Load well regions from YAML and select them in the well selector.""" From 4b454db969977d96dfeff5f2ca4bd2ca131bf730 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 02:41:14 -0800 Subject: [PATCH 5/6] fix: Correct outdated comment referencing old function name Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index e6b36c842..c0adce033 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -8221,7 +8221,7 @@ def toggle_z_range_controls(self, is_visible): widget.setVisible(is_visible) # Note: Autofocus checkbox states are now managed by on_z_mode_changed() - # via update_autofocus_checkboxes_for_z_mode() + # via update_autofocus_checkboxes() # Enable/disable NZ entry based on the inverse of is_visible self.entry_NZ.setEnabled(not is_visible) From 4f7c7b3d40eacea0be79b92165bb1d27e748b90f Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 02:52:05 -0800 Subject: [PATCH 6/6] test: Add tests for ZStackMode enum and z-stack helpers - TestZStackModeEnum: 11 tests for enum properties and values - TestCalculateZRange: 5 tests for z-range calculation - TestUpdateAutofocusCheckboxes: 7 tests for AF checkbox behavior - TestLogAfRestrictionWarnings: 5 tests for warning logging Co-Authored-By: Claude Opus 4.5 --- software/tests/control/test_z_stack_mode.py | 318 ++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 software/tests/control/test_z_stack_mode.py diff --git a/software/tests/control/test_z_stack_mode.py b/software/tests/control/test_z_stack_mode.py new file mode 100644 index 000000000..8fc71c4de --- /dev/null +++ b/software/tests/control/test_z_stack_mode.py @@ -0,0 +1,318 @@ +"""Tests for z-stack mode helpers in widgets.py.""" + +import logging +from unittest.mock import MagicMock, Mock + +import pytest + +from control.widgets import ( + ZStackMode, + calculate_z_range, + update_autofocus_checkboxes, + log_af_restriction_warnings, +) + + +class TestZStackModeEnum: + """Tests for ZStackMode enum properties.""" + + def test_from_bottom_allows_laser_af(self): + assert ZStackMode.FROM_BOTTOM.allows_laser_af is True + + def test_from_bottom_disallows_contrast_af(self): + assert ZStackMode.FROM_BOTTOM.allows_contrast_af is False + + def test_from_center_allows_contrast_af(self): + assert ZStackMode.FROM_CENTER.allows_contrast_af is True + + def test_from_center_disallows_laser_af(self): + assert ZStackMode.FROM_CENTER.allows_laser_af is False + + def test_from_top_disallows_both_af(self): + assert ZStackMode.FROM_TOP.allows_contrast_af is False + assert ZStackMode.FROM_TOP.allows_laser_af is False + + def test_set_range_disallows_both_af(self): + assert ZStackMode.SET_RANGE.allows_contrast_af is False + assert ZStackMode.SET_RANGE.allows_laser_af is False + + def test_worker_config_index_from_bottom(self): + assert ZStackMode.FROM_BOTTOM.worker_config_index == 0 + + def test_worker_config_index_from_center(self): + assert ZStackMode.FROM_CENTER.worker_config_index == 1 + + def test_worker_config_index_from_top(self): + assert ZStackMode.FROM_TOP.worker_config_index == 2 + + def test_worker_config_index_set_range_uses_from_bottom(self): + """SET_RANGE should use FROM_BOTTOM config for worker.""" + assert ZStackMode.SET_RANGE.worker_config_index == ZStackMode.FROM_BOTTOM.value + assert ZStackMode.SET_RANGE.worker_config_index == 0 + + def test_int_enum_values(self): + """Verify enum values match Z_STACKING_CONFIG_MAP indices.""" + assert int(ZStackMode.FROM_BOTTOM) == 0 + assert int(ZStackMode.FROM_CENTER) == 1 + assert int(ZStackMode.FROM_TOP) == 2 + assert int(ZStackMode.SET_RANGE) == 3 + + +class TestCalculateZRange: + """Tests for calculate_z_range() function.""" + + def test_from_bottom_z_range(self): + """FROM_BOTTOM: z_range starts at current position, goes up.""" + current_z = 1.0 # mm + dz = 10.0 # μm + nz = 5 + + min_z, max_z = calculate_z_range(current_z, dz, nz, ZStackMode.FROM_BOTTOM) + + # Total travel = 10μm * (5-1) = 40μm = 0.04mm + assert min_z == 1.0 # Start at current + assert max_z == pytest.approx(1.04) # End at current + total + + def test_from_center_z_range(self): + """FROM_CENTER: z_range centered around current position.""" + current_z = 1.0 # mm + dz = 10.0 # μm + nz = 5 + + min_z, max_z = calculate_z_range(current_z, dz, nz, ZStackMode.FROM_CENTER) + + # Total travel = 40μm = 0.04mm, half = 0.02mm + assert min_z == pytest.approx(0.98) # current - half + assert max_z == pytest.approx(1.02) # current + half + + def test_from_top_z_range(self): + """FROM_TOP: z_range ends at current position, starts below.""" + current_z = 1.0 # mm + dz = 10.0 # μm + nz = 5 + + min_z, max_z = calculate_z_range(current_z, dz, nz, ZStackMode.FROM_TOP) + + # Total travel = 40μm = 0.04mm + assert min_z == pytest.approx(0.96) # current - total + assert max_z == 1.0 # End at current + + def test_single_z_slice(self): + """With nz=1, total travel is 0.""" + current_z = 1.0 + dz = 10.0 + nz = 1 + + min_z, max_z = calculate_z_range(current_z, dz, nz, ZStackMode.FROM_BOTTOM) + + assert min_z == 1.0 + assert max_z == 1.0 + + def test_large_z_stack(self): + """Test with larger z-stack.""" + current_z = 2.0 # mm + dz = 1.0 # μm + nz = 101 # 100 steps + + min_z, max_z = calculate_z_range(current_z, dz, nz, ZStackMode.FROM_CENTER) + + # Total travel = 1μm * 100 = 100μm = 0.1mm, half = 0.05mm + assert min_z == pytest.approx(1.95) + assert max_z == pytest.approx(2.05) + + +class TestUpdateAutofocusCheckboxes: + """Tests for update_autofocus_checkboxes() function.""" + + def create_mock_checkbox(self, checked=False, enabled=True): + """Create a mock QCheckBox.""" + checkbox = MagicMock() + checkbox.isChecked.return_value = checked + checkbox.isEnabled.return_value = enabled + return checkbox + + def test_both_allowed(self): + """When both AF types allowed, both checkboxes enabled.""" + contrast_cb = self.create_mock_checkbox() + laser_cb = self.create_mock_checkbox() + + update_autofocus_checkboxes( + contrast_af_allowed=True, + laser_af_allowed=True, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + contrast_cb.setEnabled.assert_called_with(True) + laser_cb.setEnabled.assert_called_with(True) + + def test_neither_allowed(self): + """When neither AF type allowed, both checkboxes disabled.""" + contrast_cb = self.create_mock_checkbox() + laser_cb = self.create_mock_checkbox() + + update_autofocus_checkboxes( + contrast_af_allowed=False, + laser_af_allowed=False, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + contrast_cb.setEnabled.assert_called_with(False) + laser_cb.setEnabled.assert_called_with(False) + + def test_contrast_only(self): + """FROM_CENTER mode: contrast allowed, laser disabled.""" + contrast_cb = self.create_mock_checkbox() + laser_cb = self.create_mock_checkbox() + + update_autofocus_checkboxes( + contrast_af_allowed=True, + laser_af_allowed=False, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + contrast_cb.setEnabled.assert_called_with(True) + laser_cb.setEnabled.assert_called_with(False) + + def test_laser_only(self): + """FROM_BOTTOM mode: laser allowed, contrast disabled.""" + contrast_cb = self.create_mock_checkbox() + laser_cb = self.create_mock_checkbox() + + update_autofocus_checkboxes( + contrast_af_allowed=False, + laser_af_allowed=True, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + contrast_cb.setEnabled.assert_called_with(False) + laser_cb.setEnabled.assert_called_with(True) + + def test_uncheck_when_disabled_contrast(self): + """Contrast checkbox should be unchecked when disabled.""" + contrast_cb = self.create_mock_checkbox(checked=True) + laser_cb = self.create_mock_checkbox() + + update_autofocus_checkboxes( + contrast_af_allowed=False, + laser_af_allowed=True, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + contrast_cb.setChecked.assert_called_with(False) + + def test_uncheck_when_disabled_laser(self): + """Laser checkbox should be unchecked when disabled.""" + contrast_cb = self.create_mock_checkbox() + laser_cb = self.create_mock_checkbox(checked=True) + + update_autofocus_checkboxes( + contrast_af_allowed=True, + laser_af_allowed=False, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + laser_cb.setChecked.assert_called_with(False) + + def test_no_uncheck_when_not_checked(self): + """Should not call setChecked if checkbox wasn't checked.""" + contrast_cb = self.create_mock_checkbox(checked=False) + laser_cb = self.create_mock_checkbox(checked=False) + + update_autofocus_checkboxes( + contrast_af_allowed=False, + laser_af_allowed=False, + contrast_af_checkbox=contrast_cb, + laser_af_checkbox=laser_cb, + ) + + contrast_cb.setChecked.assert_not_called() + laser_cb.setChecked.assert_not_called() + + +class TestLogAfRestrictionWarnings: + """Tests for log_af_restriction_warnings() function.""" + + def test_no_warnings_when_unchanged(self): + """No warnings when AF settings match.""" + log = MagicMock() + + log_af_restriction_warnings( + yaml_contrast_af=True, + yaml_laser_af=False, + actual_contrast_af=True, + actual_laser_af=False, + log=log, + ) + + log.warning.assert_not_called() + + def test_warning_when_contrast_af_disabled(self): + """Warning logged when contrast AF was disabled.""" + log = MagicMock() + + log_af_restriction_warnings( + yaml_contrast_af=True, + yaml_laser_af=False, + actual_contrast_af=False, + actual_laser_af=False, + log=log, + ) + + log.warning.assert_called_once() + call_args = log.warning.call_args[0][0] + assert "Contrast AF was disabled" in call_args + assert "From Center" in call_args + + def test_warning_when_laser_af_disabled(self): + """Warning logged when laser AF was disabled.""" + log = MagicMock() + + log_af_restriction_warnings( + yaml_contrast_af=False, + yaml_laser_af=True, + actual_contrast_af=False, + actual_laser_af=False, + log=log, + ) + + log.warning.assert_called_once() + call_args = log.warning.call_args[0][0] + assert "Laser AF was disabled" in call_args + assert "From Bottom" in call_args + + def test_warning_when_both_disabled(self): + """Warning includes both AF types when both disabled.""" + log = MagicMock() + + log_af_restriction_warnings( + yaml_contrast_af=True, + yaml_laser_af=True, + actual_contrast_af=False, + actual_laser_af=False, + log=log, + ) + + log.warning.assert_called_once() + call_args = log.warning.call_args[0][0] + assert "Contrast AF was disabled" in call_args + assert "Laser AF was disabled" in call_args + + def test_no_warning_when_yaml_af_was_false(self): + """No warning if YAML didn't request AF in the first place.""" + log = MagicMock() + + log_af_restriction_warnings( + yaml_contrast_af=False, + yaml_laser_af=False, + actual_contrast_af=False, + actual_laser_af=False, + log=log, + ) + + log.warning.assert_not_called()