Skip to content

feat: integrate motorized 4-position objective turret#528

Open
Alpaca233 wants to merge 22 commits intomasterfrom
feat/objective-turret
Open

feat: integrate motorized 4-position objective turret#528
Alpaca233 wants to merge 22 commits intomasterfrom
feat/objective-turret

Conversation

@Alpaca233
Copy link
Copy Markdown
Collaborator

Summary

  • Integrates a motorized 4-position objective turret (NiMotion RS-485 stepper over Modbus-RTU) alongside the existing Xeryon 2-position changer; a microscope has exactly one of turret / Xeryon / manual, enforced by a new mutual-exclusion guard in _def.py.
  • Introduces a unified move_to_objective(name) interface on both controllers so the widget (ObjectivesWidget.on_objective_changed) and microscope startup each use a single call path; the Xeryon dispatcher internally preserves its pos1↔pos2 Z-offset behavior unchanged.
  • Adds a "Reset Objective Turret" action to the Utils menu (main_hcs.py) that clears faults, re-enables the motor, re-homes, and rotates back to the current objective.

What's new

  • software/control/modbus_rtu.py — Modbus-RTU client helper (pyserial-based).
  • software/control/objective_turret_controller.pyObjectiveTurret4PosController (real hardware) + ObjectiveTurret4PosControllerSimulation (CI test twin). The turret's move_to_objective retracts Z to OBJECTIVE_RETRACTED_POS_MM, rotates, then restores Z (gated on HOMING_ENABLED_Z).
  • New _def.py constants: USE_OBJECTIVE_TURRET, OBJECTIVE_TURRET_SERIAL_NUMBER, OBJECTIVE_TURRET_SLAVE_ID, OBJECTIVE_TURRET_BAUDRATE, OBJECTIVE_TURRET_POSITIONS (objective-name → 1..4 slot mapping, overridable per machine .ini). SIMULATE_OBJECTIVE_CHANGER is reused for the turret sim — no new sim flag.

Migration

No changes required for existing .ini files. USE_OBJECTIVE_TURRET defaults to False; Xeryon and manual machines stay on their current paths. A turret-equipped machine adds a small block under [GENERAL] (see design doc).

Test plan

  • python3 -m pytest --ignore=tests/control/test_HighContentScreeningGui.py — 1271 passed; only pre-existing zarr_writer failures remain
  • black --config pyproject.toml --check . — clean
  • Simulated microscope boots with turret disabled (default): addons.objective_changer == None
  • Simulated microscope boots with USE_OBJECTIVE_TURRET=True + SIMULATE_OBJECTIVE_CHANGER=True: move_to_objective('10x') updates current_objective
  • Bench test on turret hardware: confirm homing, rotation between all four slots, fault recovery via "Reset Objective Turret" menu action
  • Bench test with Xeryon hardware: confirm startup/objective-switch/shutdown behavior is unchanged through the unified dispatcher

🤖 Generated with Claude Code

Alpaca233 and others added 17 commits April 23, 2026 20:19
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire ObjectiveTurret4PosController into MicroscopeAddons.build_from_global_config
as an elif branch off the existing USE_XERYON block, using SIMULATE_OBJECTIVE_CHANGER
for the simulation flag. Update the __init__ type hint to Optional[object] to
accommodate both controller types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the Xeryon-specific pos1/pos2 dispatch in prepare_for_use with a
single move_to_objective call that works for both Xeryon and turret
controllers. The setSpeed call is kept behind USE_XERYON since the turret
has no speed setter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On full shutdown, retract Z then call moveToZero() for Xeryon or
close() for the NiMotion turret. The outer restart-gate is unchanged
so turret-only machines skip the block on restart (turret re-inits
from any position without needing a home move).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-home the turret in resetObjectiveTurret so its position tracker is
always re-synced to the physical slot. Without this, a fault mid-rotation
could leave _current_objective matching the reset target, causing
move_to_objective to short-circuit and skip the physical rotate.

Also update three stale comments/docstrings in the shutdown/restart path
that only mentioned Xeryon to reflect both controller types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
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

Integrates support for a motorized 4-position objective turret (Modbus-RTU over RS-485) and unifies objective switching across turret and Xeryon 2-position changers via a shared move_to_objective(name) interface, including GUI actions and configuration guards to ensure only one objective changer backend is enabled.

Changes:

  • Added Modbus-RTU client utilities and a 4-position turret controller (plus simulation) with Z retract/restore behavior.
  • Unified objective switching in GUI + microscope startup to call move_to_objective() regardless of backend.
  • Added config mutual-exclusion validation and new unit tests for turret simulation + objective changer flag validation.

Reviewed changes

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

Show a summary per file
File Description
software/control/modbus_rtu.py New pyserial-based Modbus-RTU client helper used by turret controller.
software/control/objective_turret_controller.py Adds real + simulated 4-position turret controller with homing and objective moves.
software/control/objective_changer_2_pos_controller.py Adds move_to_objective() dispatcher for Xeryon 2-pos controller and its simulation.
software/control/microscope.py Wires turret into microscope build path and unifies startup objective selection.
software/control/widgets.py Simplifies objective-change handling to call move_to_objective() when available.
software/control/gui_hcs.py Adds reset-turret action handler and updates cleanup logic for objective changers.
software/main_hcs.py Adds “Reset Objective Turret” menu item when turret is enabled.
software/control/_def.py Adds turret config constants + mutual-exclusion guard for Xeryon vs turret.
software/tests/control/test_objective_turret_controller.py New tests for turret simulation behavior and Z retract/restore gating.
software/tests/control/test_objective_changer_config.py New tests for mutual-exclusion config validation and turret position mapping shape.
software/tests/control/test_objective_changer_2_pos_controller.py New tests for Xeryon simulation move_to_objective() dispatcher behavior.

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

Comment on lines +372 to +452
def read_register(self, slave_id: int, address: int) -> int:
self._require_connected()
frame = build_read_registers_frame(slave_id, address, 1)
# Response: slave(1) + fc(1) + byte_count(1) + data(2) + crc(2) = 7
response = self._send_receive(frame, expected_response_len=7)
return (response[3] << 8) | response[4]

def read_register_32bit(self, slave_id: int, address: int, signed: bool = False) -> int:
self._require_connected()
frame = build_read_registers_frame(slave_id, address, 2)
# Response: slave(1) + fc(1) + byte_count(1) + data(4) + crc(2) = 9
response = self._send_receive(frame, expected_response_len=9)
high = (response[3] << 8) | response[4]
low = (response[5] << 8) | response[6]
value = (high << 16) | low
if signed and value >= 0x80000000:
value -= 0x100000000
return value

def write_register(self, slave_id: int, address: int, value: int):
self._require_connected()
frame = build_write_register_frame(slave_id, address, value)
# Response: slave(1) + fc(1) + address(2) + value(2) + crc(2) = 8
self._send_receive(frame, expected_response_len=8)

def write_register_32bit(self, slave_id: int, address: int, value: int, signed: bool = False):
self._require_connected()
if signed and value < 0:
value += 0x100000000
high = (value >> 16) & 0xFFFF
low = value & 0xFFFF
frame = build_write_multiple_registers_frame(slave_id, address, [high, low])
# Response: slave(1) + fc(1) + address(2) + quantity(2) + crc(2) = 8
self._send_receive(frame, expected_response_len=8)

def _send_receive(self, frame: bytes, expected_response_len: int) -> bytes:
with self._lock:
last_error: Optional[Exception] = None
for attempt in range(self._retries + 1):
try:
self._serial.reset_input_buffer()
self._serial.write(frame)
time.sleep(FRAME_INTERVAL)
response = self._serial.read(expected_response_len)
except (serial.SerialException, OSError) as e:
last_error = ModbusError(str(e), slave_id=frame[0])
logger.warning(f"Modbus request failed (attempt {attempt + 1}/" f"{self._retries + 1}): {e}")
if attempt < self._retries:
time.sleep(FRAME_INTERVAL * 2)
continue

# Exception responses are 5 bytes — check before incomplete check
if len(response) >= 5 and (response[1] & 0x80) and _verify_crc(response[:5]):
exception_code = response[2]
raise ModbusError(
f"Modbus exception response: FC=0x{response[1]:02X}, " f"code=0x{exception_code:02X}",
slave_id=response[0],
)

if len(response) < expected_response_len:
last_error = ModbusError(
f"Incomplete response: expected {expected_response_len} " f"bytes, got {len(response)}",
slave_id=frame[0],
)
logger.warning(
f"Modbus request failed (attempt {attempt + 1}/" f"{self._retries + 1}): {last_error}"
)
if attempt < self._retries:
time.sleep(FRAME_INTERVAL * 2)
continue

if not _verify_crc(response):
last_error = ModbusError("CRC verification failed", slave_id=frame[0])
logger.warning(
f"Modbus request failed (attempt {attempt + 1}/" f"{self._retries + 1}): {last_error}"
)
if attempt < self._retries:
time.sleep(FRAME_INTERVAL * 2)
continue

return response
Comment on lines +276 to +313
def calculate_crc(data: bytes | bytearray) -> int:
crc = 0xFFFF
for byte in data:
crc = (crc >> 8) ^ CRC16_TABLE[(crc ^ byte) & 0xFF]
return crc


def _append_crc(data: bytes | bytearray) -> bytes:
crc = calculate_crc(data)
return bytes(data) + bytes([crc & 0xFF, (crc >> 8) & 0xFF])


def _verify_crc(data: bytes | bytearray) -> bool:
if len(data) < 3:
return False
payload = data[:-2]
received_crc = data[-2] | (data[-1] << 8)
return calculate_crc(payload) == received_crc


def build_read_registers_frame(slave_id: int, address: int, count: int) -> bytes:
frame = struct.pack(">BBHH", slave_id, 0x03, address, count)
return _append_crc(frame)


def build_write_register_frame(slave_id: int, address: int, value: int) -> bytes:
frame = struct.pack(">BBHH", slave_id, 0x06, address, value)
return _append_crc(frame)


def build_write_multiple_registers_frame(slave_id: int, address: int, values: list[int]) -> bytes:
count = len(values)
byte_count = count * 2
frame = struct.pack(">BBHHB", slave_id, 0x10, address, count, byte_count)
for v in values:
frame += struct.pack(">H", v)
return _append_crc(frame)

Comment on lines 493 to +497
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)
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)
cellx: Optional[serial_peripherals.CellX] = None,
emission_filter_wheel: Optional[AbstractFilterWheelController] = None,
objective_changer: Optional[ObjectiveChanger2PosController] = None,
objective_changer: Optional[object] = None,
Comment thread software/control/gui_hcs.py Outdated
# 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()
self.objective_changer.move_to_objective(self.objectiveStore.current_objective)
Comment thread software/control/gui_hcs.py Outdated
Comment on lines +2728 to +2748
@@ -2721,9 +2740,12 @@ def _cleanup_common(self, for_restart: bool = False):
else:
raise

if USE_XERYON and self.objective_changer and z_retracted:
if self.objective_changer and z_retracted:
try:
self.objective_changer.moveToZero()
if USE_XERYON:
self.objective_changer.moveToZero()
elif USE_OBJECTIVE_TURRET:
self.objective_changer.close()
Alpaca233 and others added 5 commits April 23, 2026 22:17
Three surgical fixes from PR review:

1. resetObjectiveTurret: skip the final move_to_objective call when
   no objective has been selected yet (current_objective is None),
   instead of raising and popping an error dialog.

2. Shutdown/restart path: close the turret unconditionally — release
   the serial port so a restart can acquire it, and de-energize the
   motor on full shutdown regardless of whether Z-retract succeeded.
   Xeryon's zero-before-reinit path stays gated on z_retracted.

3. prepare_for_use: skip turret re-home on software restart. The motor
   stays powered across close()/re-init and retains its position
   register, so re-homing is wasted motion. Xeryon still re-homes
   (its findIndex is required and fast).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old implementation had a fixed 50 ms grace sleep and then returned
on the first poll that reported RUNNING=0. If the motor hadn't yet
asserted RUNNING within 50 ms (homing-method-17 to a limit switch can
take longer to spin up), _wait_until_idle would return early and the
next Modbus write in move_to_objective would hit SLAVE_BUSY (FC=0x86,
code=0x06) because the home was still executing internally.

Mirror the seen_running pattern that _wait_for_position already uses:
require RUNNING to go high at least once before accepting idle, with a
bounded grace period so trivially-short or no-op commands still return.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The NiMotion vendor FAQ confirms a known behavior: writing a parameter
or switching run modes while the motor is still in the Enabled state
returns a Modbus SLAVE_DEVICE_BUSY (exception code 0x06). The same
applies to ACKNOWLEDGE (0x05). Our code already issues CW_DISABLE
before the mode switch in _rotate_to, but there's a processing delay
between the disable landing and the motor actually transitioning out
of Enabled, and writes during that window get busy-rejected.

Handle this at the Modbus transport layer: when we see an exception
response with code 0x05 or 0x06, back off with exponential delay and
retry. These are documented "try again later" responses per the Modbus
spec; they don't indicate a failure and shouldn't consume the normal
retry budget. Cap at 20 transient retries total to avoid an infinite
loop if the motor is permanently stuck.

Also revert the seen_running change in _wait_until_idle — retry-on-busy
handles the same underlying timing race, and keeping two solutions for
one problem just obscures which one is load-bearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the NiMotion register map (and confirmed against the SingleMotor
reference driver), these registers live in the *input*-register address
space, not the holding-register space:

- 0x001A (microstep)
- 0x001F (motion status word)
- 0x0021 (current display position)

Our Modbus client only supported FC 0x03 (read holding), so reads of
these addresses were returning unrelated holding-register values at the
same numeric address (e.g. holding 0x001F is "drive parameters", not
the status word). This is why _wait_for_position saw impossible values
like 786434 pulses and timed out waiting for a position that would
never be reported.

Add read_input_register and read_input_register_32bit (FC 0x04) to the
Modbus client, and use them in the turret controller for the three
input-register reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The microstep register (0x001A) is in the holding-register space per
the NiMotion register map (SingleMotor's register catalog confirms;
there is no input register at 0x001A). My prior commit mistakenly
routed this read through FC 0x04, so the device returned garbage
(typically 0), which made self._microstep = 2**0 = 1 (full-step mode)
and collapsed pulses_per_position to 137 — hence "move to 137 pulses,
last position 449" with the motor off-slot.

The status word (0x001F) and current position (0x0021) remain on FC
0x04 — those genuinely are input registers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants