Skip to content

feat: Add 'From Center' z-stack mode with autofocus restrictions#483

Open
hongquanli wants to merge 9 commits intomasterfrom
feat/z-stack-ux-improvement
Open

feat: Add 'From Center' z-stack mode with autofocus restrictions#483
hongquanli wants to merge 9 commits intomasterfrom
feat/z-stack-ux-improvement

Conversation

@hongquanli
Copy link
Contributor

@hongquanli hongquanli commented Jan 28, 2026

Summary

  • Add "From Center" z-stack mode to both WellplateMultiPointWidget and FlexibleMultiPointWidget
  • Add ZStackMode IntEnum with properties (allows_contrast_af, allows_laser_af, worker_config_index)
  • Extract shared helpers: calculate_z_range(), update_autofocus_checkboxes(), log_af_restriction_warnings()
  • Implement autofocus restrictions based on z-stack mode:
    • Contrast AF: only enabled for "From Center" mode (focus plane is at center)
    • Laser AF: only enabled for "From Bottom" mode (can find surface before z-stack)
    • Both disabled for "Set Range" and "From Top"
  • Fix FROM CENTER worker flow: initialize_z_stack() moves to center for correct AF, prepare_z_stack() moves to bottom before imaging
  • Fix pre-existing unit conversion bug in WellplateMultiPointWidget (μm not converted to mm in z-range calculation)
  • Add validation and warnings for YAML/cache loading of z-stack modes
  • Replace print() with proper logging in set_z_stacking_config()

Test plan

  • Test "From Center" mode: verify z-stack is centered around current position
  • Test "From Bottom" mode: verify z-stack starts at current position going up
  • Test AF checkbox states update correctly when changing z-mode
  • Test YAML loading with different z_stacking_config values shows appropriate warnings
  • Test cache loading restores z-mode and syncs AF checkbox states
  • Unit tests: 28 tests for ZStackMode enum, calculate_z_range, update_autofocus_checkboxes, log_af_restriction_warnings

🤖 Generated with Claude Code

hongquanli and others added 6 commits January 28, 2026 01:30
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “From Center” z-stack mode to the multipoint UI and enforces autofocus (AF) compatibility rules based on the selected z-stack mode, with additional YAML/cache load warnings and helper-unit tests.

Changes:

  • Introduces a shared ZStackMode enum plus helpers to compute z-range and enable/disable AF checkboxes.
  • Updates both multipoint widgets to expose “From Center” and synchronize AF checkbox state + YAML/cache loading behavior.
  • Simplifies the worker’s prepare_z_stack() and replaces a print with structured logging in the controller.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
software/control/widgets.py Adds ZStackMode + z-range/AF helpers; wires “From Center” mode into UI, YAML/cache sync, and z-range calculation.
software/control/core/multi_point_worker.py Removes FROM CENTER repositioning in prepare_z_stack() (now only stabilization sleep).
software/control/core/multi_point_controller.py Uses logger instead of print, and warns on invalid z-stacking indices.
software/tests/control/test_z_stack_mode.py Adds unit tests for ZStackMode and helper functions.
Comments suppressed due to low confidence (1)

software/control/widgets.py:5883

  • In FlexibleMultiPointWidget, enabling “Set Z-range” no longer updates autofocus checkbox states. toggle_z_range_controls() used to disable reflection AF when the Z-range UI is shown, but now AF is only updated via on_z_stack_mode_changed(), which is unrelated to the Set Z-range checkbox. Consider treating Set Z-range as ZStackMode.SET_RANGE and calling update_autofocus_checkboxes(...) (and possibly set_z_stacking_config(mode.worker_config_index)) when the checkbox toggles so AF restrictions stay consistent.
    def toggle_z_range_controls(self, state):
        is_visible = bool(state)

        # Hide/show widgets in z_min_layout
        for i in range(self.z_min_layout.count()):
            widget = self.z_min_layout.itemAt(i).widget()
            if widget is not None:
                widget.setVisible(is_visible)
            widget = self.z_max_layout.itemAt(i).widget()
            if widget is not None:
                widget.setVisible(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
        self.entry_minZ.setValue(current_z)
        if is_visible:
            self._reset_reflection_af_reference()
        self.entry_maxZ.setValue(current_z)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -1282,10 +1282,7 @@ def perform_autofocus(self, region_id, fov):
return True

def prepare_z_stack(self):
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepare_z_stack() no longer repositions the stage for FROM CENTER, but perform_autofocus() still runs before prepare_z_stack() and assumes the current Z is the stack center, and move_z_back_after_stack() still contains FROM CENTER-specific logic. With the new behavior, subsequent FOVs will start imaging from the AF plane (center) instead of the stack start, and the first FOV may autofocus at the stack start (z_range[0]) rather than the center. Either restore a FROM CENTER reposition here (preferably moving to z_range[0] relative to the post-AF center), or update the worker to consistently use z_range/midpoint for AF and stack start/end across FOVs.

Suggested change
def prepare_z_stack(self):
def prepare_z_stack(self):
# For FROM CENTER stacking, autofocus leaves us at the stack center.
# Move to the start of the z range (relative to this center) before imaging.
if getattr(self, "NZ", 1) > 1 and getattr(self, "z_stacking_config", None) == "FROM CENTER":
try:
start_offset_um = self.z_range[0]
except (AttributeError, IndexError, TypeError):
start_offset_um = None
if start_offset_um is not None:
self._log.info(
"Preparing z stack (FROM CENTER): moving to start offset %.3f µm from AF plane",
start_offset_um,
)
# z_range values are in µm; convert to mm for the stage.move_z API.
self.stage.move_z(start_offset_um / 1000.0)
self.wait_till_operation_is_completed()

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in commit 03f9890 - Restored FROM CENTER logic: initialize_z_stack() now moves to center (midpoint of z_range) so AF runs at correct Z level, and prepare_z_stack() moves from center to bottom before imaging. move_z_back_after_stack() was already correct.

Comment on lines 8989 to 9001
# 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")
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)
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wellplate YAML z-mode parsing only warns for FROM TOP, but any other unknown z_stacking_config value silently falls back to From Bottom via dict.get(..., "From Bottom"). If the intent is to warn on unsupported/unknown YAML values (per PR description and the flexible widget behavior), add a warning when yaml_data.z_stacking_config is not in z_mode_map (and not one of the explicitly handled unsupported values).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in commit 03f9890 - Now warns on any unknown z_stacking_config value (not just FROM TOP). Uses dict.get() without default and checks for None.

Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'logging' is not used.

Suggested change
import logging

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in commit 03f9890 - Removed unused logging import.

Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'Mock' is not used.

Suggested change
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in commit 03f9890 - Removed unused Mock import.

- Restore FROM CENTER logic in worker: initialize_z_stack() moves to
  center (for correct AF), prepare_z_stack() moves from center to bottom
- Fix wellplate YAML: warn on any unknown z_stacking_config, not just FROM TOP
- Remove unused imports (logging, Mock) in test file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 5865 to 5876
@@ -5738,8 +5874,6 @@ def toggle_z_range_controls(self, state):
if widget is not None:
widget.setVisible(is_visible)

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When toggling the "Set Z-range" checkbox, the autofocus checkbox states should be updated to reflect SET_RANGE mode restrictions (both AF types disabled). Currently, only the combobox_z_stack change triggers AF checkbox updates via on_z_stack_mode_changed(), but checking/unchecking checkbox_set_z_range doesn't update the AF checkboxes. This means users can have AF enabled in SET_RANGE mode, which shouldn't be allowed according to the ZStackMode.SET_RANGE.allows_contrast_af and allows_laser_af properties.

Add a call to update_autofocus_checkboxes() in toggle_z_range_controls() to disable both AF checkboxes when is_visible is True (SET_RANGE mode), and re-enable them based on the current combobox_z_stack mode when is_visible is False.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in commit ce81cfa - toggle_z_range_controls() now disables both AF checkboxes when Set Z-range is checked, and restores them based on current combobox_z_stack mode when unchecked.

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The z_mode mapping only explicitly handles "From Center", falling back to FROM_BOTTOM for all other modes. While this works for the current combobox options ("From Bottom", "From Center", "Set Range"), it would silently use the wrong mode if "From Top" were added to the combobox in the future.

Consider using a mode_map dictionary similar to on_z_mode_changed() (lines 7916-7920) for consistency and maintainability. For example:

mode_map = {
"From Bottom": ZStackMode.FROM_BOTTOM,
"From Center": ZStackMode.FROM_CENTER,
}
mode = mode_map.get(z_mode, ZStackMode.FROM_BOTTOM)

Suggested change
mode = ZStackMode.FROM_CENTER if z_mode == "From Center" else ZStackMode.FROM_BOTTOM
mode_map = {
"From Bottom": ZStackMode.FROM_BOTTOM,
"From Center": ZStackMode.FROM_CENTER,
}
mode = mode_map.get(z_mode, ZStackMode.FROM_BOTTOM)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in commit ce81cfa - Now uses a dict mapping for consistency with on_z_mode_changed().

hongquanli and others added 2 commits February 17, 2026 00:18
- FlexibleMultiPointWidget: toggle_z_range_controls() now updates AF
  checkboxes (both disabled when Set Z-range checked, restored when unchecked)
- WellplateMultiPointWidget: use dict mapping for z_mode in toggle_acquisition
  for consistency and future-proofing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Abort acquisition on invalid z-stack mode instead of silently falling
back. Add try-except guards, combobox reset on fallback, validate
z_stacking_config in worker, and reject SET_RANGE in calculate_z_range.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

software/control/core/multi_point_worker.py:1301

  • The prepare_z_stack() method has a double sleep that results in 2x the expected stabilization time for FROM CENTER mode. Line 1300 sleeps after moving Z, and line 1301 always sleeps again. For FROM CENTER mode, this causes 2 * SCAN_STABILIZATION_TIME_MS_Z sleep, while other modes only sleep once. Either remove the redundant sleep on line 1301, or restructure the logic to ensure consistent stabilization time across all modes.
    def prepare_z_stack(self):
        # For FROM CENTER: autofocus ran at center, now move to bottom before imaging
        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)
        self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"""
if mode == ZStackMode.SET_RANGE:
raise ValueError(
"calculate_z_range() should not be called with SET_RANGE mode. " "Use user-specified Z min/max directly."
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String concatenation splits the error message across lines, which may cause issues with some linters and makes the code harder to read. Consider using a single string or proper string formatting with parentheses for multi-line strings.

Suggested change
"calculate_z_range() should not be called with SET_RANGE mode. " "Use user-specified Z min/max directly."
"calculate_z_range() should not be called with SET_RANGE mode. Use user-specified Z min/max directly."

Copilot uses AI. Check for mistakes.
mode = ZStackMode(self.combobox_z_stack.currentIndex())
except ValueError:
self._log.error(
f"Invalid z-stack mode index: {self.combobox_z_stack.currentIndex()}. " f"Aborting acquisition."
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String concatenation splits the error message across lines, which may cause issues with some linters and makes the code harder to read. Consider using a single string or proper string formatting with parentheses for multi-line strings.

Suggested change
f"Invalid z-stack mode index: {self.combobox_z_stack.currentIndex()}. " f"Aborting acquisition."
f"Invalid z-stack mode index: {self.combobox_z_stack.currentIndex()}. Aborting acquisition."

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments