feat: integrate motorized 4-position objective turret#528
Open
feat: integrate motorized 4-position objective turret#528
Conversation
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>
Contributor
There was a problem hiding this comment.
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, |
| # 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 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() | |||
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_def.py.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.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.py—ObjectiveTurret4PosController(real hardware) +ObjectiveTurret4PosControllerSimulation(CI test twin). The turret'smove_to_objectiveretracts Z toOBJECTIVE_RETRACTED_POS_MM, rotates, then restores Z (gated onHOMING_ENABLED_Z)._def.pyconstants: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_CHANGERis reused for the turret sim — no new sim flag.Migration
No changes required for existing
.inifiles.USE_OBJECTIVE_TURRETdefaults toFalse; 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-existingzarr_writerfailures remainblack --config pyproject.toml --check .— cleanaddons.objective_changer == NoneUSE_OBJECTIVE_TURRET=True+SIMULATE_OBJECTIVE_CHANGER=True:move_to_objective('10x')updatescurrent_objective🤖 Generated with Claude Code