Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fed80b8
feat: add modbus RTU client helper for turret controller
Alpaca233 Apr 24, 2026
776ff4a
fix: use __future__ annotations in modbus_rtu for py38/py39
Alpaca233 Apr 24, 2026
56de368
feat: add objective turret _def constants and mutual-exclusion guard
Alpaca233 Apr 24, 2026
6bb036c
test: add failing tests for ObjectiveTurret4PosControllerSimulation
Alpaca233 Apr 24, 2026
6603b36
feat: add ObjectiveTurret4PosControllerSimulation with Z retract/restore
Alpaca233 Apr 24, 2026
62a91be
refactor: drop unused imports and add stage type hint on turret sim
Alpaca233 Apr 24, 2026
4190b58
feat: add ObjectiveTurret4PosController (real Modbus-RTU controller)
Alpaca233 Apr 24, 2026
7cab3a0
docs: note why _wait_for_position has no leading sleep
Alpaca233 Apr 24, 2026
b2a3f6e
feat: add move_to_objective dispatcher to Xeryon 2-pos controller
Alpaca233 Apr 24, 2026
cc86c82
feat: build objective turret addon in MicroscopeAddons
Alpaca233 Apr 24, 2026
f28eacf
refactor: unify objective-changer startup via move_to_objective
Alpaca233 Apr 24, 2026
a18ec65
refactor: unify ObjectivesWidget dispatch via move_to_objective
Alpaca233 Apr 24, 2026
103535e
feat: dispatch objective-changer shutdown by controller type
Alpaca233 Apr 24, 2026
25caed5
feat: add resetObjectiveTurret handler on HighContentScreeningGui
Alpaca233 Apr 24, 2026
9311477
refactor: drop redundant QMessageBox local import in resetObjectiveTu…
Alpaca233 Apr 24, 2026
44bd390
feat: add Reset Objective Turret entry to Utils menu
Alpaca233 Apr 24, 2026
8f91a4b
fix: make Reset Objective Turret re-home before rotating
Alpaca233 Apr 24, 2026
fde7fe4
fix: address Copilot review on turret lifecycle
Alpaca233 Apr 24, 2026
fca3afd
fix: wait for RUNNING assertion in _wait_until_idle
Alpaca233 Apr 24, 2026
c7a3f8b
fix: retry on Modbus slave-busy responses
Alpaca233 Apr 24, 2026
02855ba
fix: use FC 0x04 (read input) for NiMotion input registers
Alpaca233 Apr 24, 2026
7b277d1
fix: read microstep via FC 0x03 (it's a holding register)
Alpaca233 Apr 24, 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
19 changes: 19 additions & 0 deletions software/control/_def.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,22 @@ def get_wellplate_settings(wellplate_format):
XERYON_OBJECTIVE_SWITCHER_POS_2 = ["20x", "40x", "60x"]
XERYON_OBJECTIVE_SWITCHER_POS_2_OFFSET_MM = 2

# Motorized 4-position objective turret (NiMotion RS-485 stepper, Modbus-RTU)
USE_OBJECTIVE_TURRET = False
OBJECTIVE_TURRET_SERIAL_NUMBER = ""
OBJECTIVE_TURRET_SLAVE_ID = 1
OBJECTIVE_TURRET_BAUDRATE = 115200
# Objective name -> turret slot index (1..4). Override per machine in .ini.
OBJECTIVE_TURRET_POSITIONS = {"4x": 1, "10x": 2, "20x": 3, "40x": 4}


def _validate_objective_changer_flags(use_xeryon: bool, use_turret: bool) -> None:
if use_xeryon and use_turret:
raise ValueError(
"USE_XERYON and USE_OBJECTIVE_TURRET are mutually exclusive " "(set only one to True in the machine .ini)"
)


# fluidics
RUN_FLUIDICS = False
FLUIDICS_CONFIG_PATH = "./merfish_config/MERFISH_config.json"
Expand Down Expand Up @@ -1325,6 +1341,8 @@ class SlackNotifications:
myclass = locals()[classkey]
populate_class_from_dict(myclass, pop_items)

_validate_objective_changer_flags(USE_XERYON, USE_OBJECTIVE_TURRET)

with open("cache/config_file_path.txt", "w") as file:
file.write(config_files[0])
CACHED_CONFIG_FILE_PATH = config_files[0]
Expand All @@ -1337,6 +1355,7 @@ class SlackNotifications:
sys.exit(1)
log.info("load machine-specific configuration")
exec(open(config_files[0]).read())
_validate_objective_changer_flags(USE_XERYON, USE_OBJECTIVE_TURRET)
else:
log.error("machine-specific configuration not present, the program will exit")
sys.exit(1)
Expand Down
54 changes: 43 additions & 11 deletions software/control/gui_hcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,10 +878,7 @@ def load_widgets(self):
if self.piezo:
self.piezoWidget = widgets.PiezoWidget(self.piezo)

if USE_XERYON:
self.objectivesWidget = widgets.ObjectivesWidget(self.objectiveStore, self.objective_changer)
else:
self.objectivesWidget = widgets.ObjectivesWidget(self.objectiveStore)
self.objectivesWidget = widgets.ObjectivesWidget(self.objectiveStore, self.objective_changer)

if self.emission_filter_wheel:
self.filterControllerWidget = widgets.FilterControllerWidget(
Expand Down Expand Up @@ -1928,6 +1925,28 @@ def openWorkflowRunner(self):
self.workflowRunnerDialog.raise_()
self.workflowRunnerDialog.activateWindow()

def resetObjectiveTurret(self):
"""Clear faults, re-enable the motor, re-home, and rotate back to the current objective."""
if self.objective_changer is None:
return
self.log.info("Resetting objective turret")
try:
self.objective_changer.clear_alarm()
self.objective_changer.enable()
# Re-home so the position tracker matches the physical slot: avoids short-circuiting the
# rotate in move_to_objective if the tracker is stale from a fault mid-rotation.
self.objective_changer.home()
current = self.objectiveStore.current_objective
if current:
self.objective_changer.move_to_objective(current)
except Exception as exc:
self.log.exception("Reset of objective turret failed")
QMessageBox.warning(
self,
"Reset Objective Turret",
f"Failed to reset objective turret:\n{exc}",
)

def _get_actual_acquisition_path(self) -> str:
"""Get the actual acquisition path (base_path + experiment_ID with timestamp)."""
if hasattr(self, "multipointController") and self.multipointController:
Expand Down Expand Up @@ -2602,8 +2621,9 @@ def _cleanup_common(self, for_restart: bool = False):

Args:
for_restart: If True, wrap operations in try-except to ensure cleanup completes.
Z retraction and objective reset still run when using Xeryon
(Xeryon must be zeroed before re-init), but are skipped otherwise.
Z retraction and objective-changer teardown still run when using Xeryon
(Xeryon must be zeroed before re-init). For a turret or a manual setup,
restart skips this block (turret re-inits cleanly from any position).
"""
context = "restart" if for_restart else "shutdown"

Expand Down Expand Up @@ -2707,9 +2727,8 @@ def _cleanup_common(self, for_restart: bool = False):
else:
raise

# Retract Z and reset objective changer on full shutdown.
# On restart, only retract Z and reset if Xeryon objective changer is present
# (Xeryon must be zeroed before re-init; Z must retract first for safety).
# Z-retract + Xeryon zero: needed on full shutdown, and on Xeryon restart
# (Xeryon must be zeroed before re-init).
if not for_restart or USE_XERYON:
z_retracted = False
try:
Expand All @@ -2726,10 +2745,22 @@ def _cleanup_common(self, for_restart: bool = False):
self.objective_changer.moveToZero()
except Exception:
if for_restart:
self.log.exception(f"Error resetting objective changer during {context}")
self.log.exception(f"Error resetting Xeryon during {context}")
else:
raise

# Turret close: always release the serial port so the new process (on restart)
# can acquire it, and so the motor is de-energized on full shutdown. Independent
# of Z-retract success — the close path must run even if Z retract failed.
if USE_OBJECTIVE_TURRET and self.objective_changer:
try:
self.objective_changer.close()
except Exception:
if for_restart:
self.log.exception(f"Error closing turret during {context}")
else:
raise

if not for_restart:
self.microcontroller.turn_off_all_pid()

Expand Down Expand Up @@ -2775,7 +2806,8 @@ def _cleanup_common(self, for_restart: bool = False):
raise

def _cleanup_for_restart(self):
"""Clean up hardware and resources for restart. Retracts Z and resets Xeryon if present."""
"""Clean up hardware and resources for restart. Retracts Z and zeros Xeryon if present
(skipped for turret or manual setups; the turret re-inits cleanly from any position)."""
self._cleanup_common(for_restart=True)

def closeEvent(self, event):
Expand Down
37 changes: 30 additions & 7 deletions software/control/microscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
else:
ObjectiveChanger2PosController = None

if control._def.USE_OBJECTIVE_TURRET:
from control.objective_turret_controller import (
ObjectiveTurret4PosController,
ObjectiveTurret4PosControllerSimulation,
)
else:
ObjectiveTurret4PosController = None

if control._def.RUN_FLUIDICS:
from control.fluidics import Fluidics
else:
Expand Down Expand Up @@ -129,6 +137,19 @@ def build_from_global_config(
if not objective_changer_simulated
else ObjectiveChanger2PosController_Simulation(sn=control._def.XERYON_SERIAL_NUMBER, stage=stage)
)
elif control._def.USE_OBJECTIVE_TURRET:
turret_kwargs = dict(
serial_number=control._def.OBJECTIVE_TURRET_SERIAL_NUMBER,
slave_id=control._def.OBJECTIVE_TURRET_SLAVE_ID,
baudrate=control._def.OBJECTIVE_TURRET_BAUDRATE,
positions=control._def.OBJECTIVE_TURRET_POSITIONS,
stage=stage,
)
objective_changer = (
ObjectiveTurret4PosController(**turret_kwargs)
if not objective_changer_simulated
else ObjectiveTurret4PosControllerSimulation(**turret_kwargs)
)

camera_focus = None
if control._def.SUPPORT_LASER_AUTOFOCUS:
Expand Down Expand Up @@ -184,7 +205,7 @@ def __init__(
nl5: Optional[NL5] = None,
cellx: Optional[serial_peripherals.CellX] = None,
emission_filter_wheel: Optional[AbstractFilterWheelController] = None,
objective_changer: Optional[ObjectiveChanger2PosController] = None,
objective_changer: Optional[object] = None,
camera_focus: Optional[AbstractCamera] = None,
fluidics: Optional[Fluidics] = None,
piezo_stage: Optional[PiezoStage] = None,
Expand Down Expand Up @@ -470,12 +491,14 @@ def _prepare_for_use(self, skip_init: bool = False):
raise

if self.addons.objective_changer:
self.addons.objective_changer.home()
self.addons.objective_changer.setSpeed(control._def.XERYON_SPEED)
if control._def.DEFAULT_OBJECTIVE in control._def.XERYON_OBJECTIVE_SWITCHER_POS_1:
self.addons.objective_changer.moveToPosition1(move_z=False)
elif control._def.DEFAULT_OBJECTIVE in control._def.XERYON_OBJECTIVE_SWITCHER_POS_2:
self.addons.objective_changer.moveToPosition2(move_z=False)
# Xeryon always re-homes (findIndex is fast and required). The turret skips
# homing on a software restart: the motor stays powered across close()/re-init
# and retains its position register, so a re-home would just be wasted motion.
if control._def.USE_XERYON or not skip_init:
self.addons.objective_changer.home()
if control._def.USE_XERYON:
self.addons.objective_changer.setSpeed(control._def.XERYON_SPEED)
self.addons.objective_changer.move_to_objective(control._def.DEFAULT_OBJECTIVE)
Comment on lines 493 to +501

def _sync_confocal_mode_from_hardware(self) -> bool:
"""Sync confocal mode state from spinning disk hardware.
Expand Down
Loading
Loading