From 20bf12c3077326496bd6ec457fe701f53e365763 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Sat, 27 Jun 2026 08:54:50 +0200 Subject: [PATCH 01/22] Add selectable Agilent and legacy V11 VSpin backends Keep the existing Agilent VSpin backend as the default path for newer Agilent-branded centrifuges. Add a separate Velocity11 legacy backend for the trace-derived V11 communication sequence and expose create_vspin_backend(..., variant='agilent'|'v11') for explicit selection. Note: hardware validation for the legacy V11 path is still pending; these changes are untested on real Velocity11 hardware. --- pylabrobot/centrifuge/__init__.py | 3 +- pylabrobot/centrifuge/v11_vspin_backend.py | 853 +++++++++++++++++++ pylabrobot/centrifuge/vspin_backend.py | 21 + pylabrobot/centrifuge/vspin_backend_tests.py | 140 +++ 4 files changed, 1016 insertions(+), 1 deletion(-) create mode 100644 pylabrobot/centrifuge/v11_vspin_backend.py create mode 100644 pylabrobot/centrifuge/vspin_backend_tests.py diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index f910c01a64e..4544a56729d 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -14,4 +14,5 @@ LoaderNoPlateError, NotAtBucketError, ) -from .vspin_backend import Access2Backend, VSpinBackend +from .v11_vspin_backend import V11VSpinBackend +from .vspin_backend import Access2Backend, VSpinBackend, create_vspin_backend diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py new file mode 100644 index 00000000000..fdf9f5be47f --- /dev/null +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -0,0 +1,853 @@ +import asyncio +import ctypes +import json +import logging +import os +import time +import warnings +from typing import Optional + +from pylabrobot.io.ftdi import FTDI + +from .backend import CentrifugeBackend + +logger = logging.getLogger(__name__) + + +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", +) + + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + f"Using the default bucket 1 offset of home + {DEFAULT_BUCKET_1_OFFSET} ticks. " + "Use `set_bucket_1_position_to_current` after setup to override it.", + UserWarning, + ) + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 +DEFAULT_BUCKET_1_OFFSET: int = 5337 +DEFAULT_BUCKET_1_REMAINDER: int = -DEFAULT_BUCKET_1_OFFSET +DEFAULT_READ_TIMEOUT: float = 0.2 +STATUS_POLL_INTERVAL: float = 0.08 +POSITION_TOLERANCE: int = 15 +TACH_TO_RPM: float = -14.69320388 + +_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} + + +def _with_vspin_checksum(cmd: bytes) -> bytes: + """Return ``cmd`` with the final VSpin checksum byte recomputed.""" + if len(cmd) <= 2 or cmd[0] != 0xAA: + return cmd + payload = cmd[1:-1] + return b"\xaa" + payload + bytes([sum(payload) & 0xFF]) + + +def _build_vspin_position_command(position: int) -> bytes: + position_bytes = int(position).to_bytes(4, byteorder="little") + payload = b"\x01\xd4\x97" + position_bytes + bytes.fromhex("c3f52800d71a0000") + return _with_vspin_checksum(b"\xaa" + payload + b"\x00") + + +def _build_vspin_spin_command( + current_position: int, + rpm: int, + duration: float, + acceleration: float, +) -> tuple[bytes, int]: + ticks_per_second = (int(rpm) / 60.0) * FULL_ROTATION + acceleration_ticks_per_second2 = 12903.2 * float(acceleration) + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + distance_at_speed = ticks_per_second * float(duration) + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + position_bytes = final_position.to_bytes(4, byteorder="little") + rpm_bytes = int(int(rpm) * 4473.925).to_bytes(4, byteorder="little") + acceleration_bytes = int(9.15 * 100 * float(acceleration)).to_bytes( + 4, byteorder="little" + ) + payload = b"\x01\xd4\x97" + position_bytes + rpm_bytes + acceleration_bytes + return _with_vspin_checksum(b"\xaa" + payload + b"\x00"), final_position + + +def _build_vspin_deceleration_command(deceleration: float) -> bytes: + deceleration_bytes = int(9.15 * 100 * float(deceleration)).to_bytes( + 2, byteorder="little" + ) + return _with_vspin_checksum( + bytes.fromhex("aa0194b600000000") + deceleration_bytes + b"\x00\x00\x00" + ) + + +bucket_1_not_set_error = RuntimeError( + "Bucket 1 position not set. " + "Please rotate the bucket to bucket 1 using V11VSpinBackend.go_to_position and " + "then calling V11VSpinBackend.set_bucket_1_position_to_current." +) + + +class V11VSpinBackend(CentrifugeBackend): + """Backend for legacy Velocity11 VSpin centrifuges. + + Velocity11 was acquired by Agilent, but this legacy command path is kept + separate from the Agilent VSpin backend because the startup and polling + behavior observed on older Velocity11 units is not known to be compatible + with newer Agilent-branded centrifuges. + + Hardware validation is still pending for this port; test on real Velocity11 + hardware before relying on it in production. + """ + + def __init__(self, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. + Find using `python -m pylibftdi.examples.list_devices`. + """ + self.io = FTDI(human_readable_device_name="Velocity11 VSpin Centrifuge", device_id=device_id) + self._bucket_1_remainder: Optional[int] = DEFAULT_BUCKET_1_REMAINDER + self._last_position: int = 0 + self._last_home_position: int = 0 + self._motion_is_prepared = False + self._stop_requested = False + self._command_lock: Optional[asyncio.Lock] = None + # only attempt loading calibration if device_id is not None + # if it is None, we will load it after setup when we can query the device id from the io + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) or DEFAULT_BUCKET_1_REMAINDER + + async def setup(self): + await self.io.setup() + self._command_lock = asyncio.Lock() + self._motion_is_prepared = False + + await self.configure_and_initialize() + await self._startup_handshake() + await self._enable_telemetry_and_pneumatics() + await self._home_rotor() + + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder == DEFAULT_BUCKET_1_REMAINDER: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) or DEFAULT_BUCKET_1_REMAINDER + + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = remainder + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration. + Normally it is the home position minus the remainder (calibration). + The bucket 1 position must be greater than the current position, so we find + the first position greater than the current position by adding full rotations if needed. + """ + return await self._get_bucket_position(1) + + async def _get_bucket_position(self, bucket_num: int) -> int: + if bucket_num not in (1, 2): + raise ValueError("bucket_num must be 1 or 2") + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.get_home_position() + current_position = await self.get_position() + target_position = home_position - self.bucket_1_remainder + if bucket_num == 2: + target_position += FULL_ROTATION // 2 + + while target_position <= current_position + POSITION_TOLERANCE: + target_position += FULL_ROTATION + + return target_position + + async def stop(self): + await self.configure_and_initialize() + await self.io.stop() + + class _StatusPositionTachometer(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("status", ctypes.c_uint8), + ("current_position", ctypes.c_uint32), + ("unknown1", ctypes.c_uint8), + ("tachometer", ctypes.c_int16), + ("unknown2", ctypes.c_uint8), + ("home_position", ctypes.c_uint32), + ("checksum", ctypes.c_uint8), + ] + + @staticmethod + def _make_status( + status: int, + current_position: int, + unknown1: int = 0, + tachometer: int = 0, + unknown2: int = 0, + home_position: int = 0, + checksum: int = 0, + ) -> _StatusPositionTachometer: + parsed = V11VSpinBackend._StatusPositionTachometer() + parsed.status = status + parsed.current_position = current_position + parsed.unknown1 = unknown1 + parsed.tachometer = tachometer + parsed.unknown2 = unknown2 + parsed.home_position = home_position + parsed.checksum = checksum + return parsed + + @staticmethod + def _find_status_packet(resp: bytes) -> Optional[_StatusPositionTachometer]: + for start in range(max(0, len(resp) - 13)): + packet = resp[start : start + 14] + if len(packet) < 14 or packet[0] not in _KNOWN_VSPIN_STATUSES: + continue + if (sum(packet[:-1]) & 0xFF) != packet[-1]: + continue + return V11VSpinBackend._StatusPositionTachometer.from_buffer_copy(packet) + return None + + @staticmethod + def _find_short_status(resp: bytes) -> Optional[int]: + if len(resp) == 5 and resp[0] == 0x00 and (sum(resp[:-1]) & 0xFF) == resp[-1]: + if resp[2] in _KNOWN_VSPIN_STATUSES: + return resp[2] + + for index, value in enumerate(resp): + if value not in _KNOWN_VSPIN_STATUSES: + continue + if len(resp) == 1: + return value + if index + 1 < len(resp) and resp[index + 1] == value: + return value + return None + + def _parse_position_status(self, resp: bytes) -> _StatusPositionTachometer: + full_status = self._find_status_packet(resp) + if full_status is not None: + return full_status + + short_status = self._find_short_status(resp) + if short_status is not None: + return self._make_status( + status=short_status, + current_position=self._last_position, + home_position=self._last_home_position, + ) + + return self._make_status( + status=0x19, + current_position=self._last_position, + home_position=self._last_home_position, + ) + + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: + """Returns 14 bytes + + Example: + 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 + ^^ checksum + ^^ ^^ ^^ ^^ home position + ^^ ? (probably binary status objects) + ^^ ^^ tachometer + ^^ ? (probably binary status objects) + ^^ ^^ ^^ ^^ current position + ^^ + - First byte (index 0): + - 11 = 0b0001011 = idle + - 13 = 0b0001101 = unknown + - 08 = 0b0001000 = spinning + - 09 = 0b0001001 = also spinning but different + - 19 = 0b0010011 = unknown + - 88 = 0b1011000 = unknown + - 89 = 0b1011001 = unknown + - 10th to 13th byte (index 9-12) = Homing Position + - Last byte (index 13) = checksum + """ + resp = await self._send_command(bytes.fromhex("aa010e0f"), expected_len=14) + status = self._parse_position_status(resp) + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + return status + + async def get_position(self) -> int: + return (await self._get_positions_and_tachometer()).current_position # type: ignore + + async def get_tachometer(self) -> int: + """current speed in rpm""" + return (await self._get_positions_and_tachometer()).tachometer * TACH_TO_RPM # type: ignore + + async def get_home_position(self) -> int: + """changes during a run, but the bucket 1 position relative to it does not""" + return (await self._get_positions_and_tachometer()).home_position # type: ignore + + async def _get_status(self): + """ + examples: + - 0080d0015 + - 0080f0015 + """ + + resp = await self._send_command(bytes.fromhex("aa020e10"), expected_len=5) + if len(resp) < 3: + raise IOError(f"Invalid status from centrifuge: {resp.hex() or '(empty)'}") + return resp + + async def get_bucket_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0001 != 0 # type: ignore + + async def get_door_open(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0010 != 0 # type: ignore + + async def get_door_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # Centrifuge communication: read_resp, send + + async def _read_resp( + self, + timeout: float = DEFAULT_READ_TIMEOUT, + expected_len: Optional[int] = None, + quiet_time: float = 0.05, + ) -> bytes: + """Read raw binary VSpin responses. + + VSpin status replies are usually 2, 5, or 14 raw bytes and do not end in + CR. Waiting for 0x0d creates avoidable timeouts on normal status polling. + """ + data = b"" + start_time = time.monotonic() + last_data_time: Optional[float] = None + + while time.monotonic() - start_time < timeout: + chunk = await self.io.read(25) + if chunk: + data += bytes(chunk) + last_data_time = time.monotonic() + if expected_len is not None and len(data) >= expected_len: + break + continue + + if ( + data + and expected_len is None + and last_data_time is not None + and time.monotonic() - last_data_time >= quiet_time + ): + break + + await asyncio.sleep(0.003) + + logger.debug("[vspin] Read %s", data.hex()) + return data + + async def _send_safe( + self, + cmd: bytes, + retries: int = 3, + timeout: float = DEFAULT_READ_TIMEOUT, + expect_response: bool = True, + expected_len: Optional[int] = None, + ) -> bytes: + for attempt in range(1, retries + 1): + resp = await self._send_command( + cmd, + read_timeout=timeout, + expected_len=expected_len, + ) + if resp or not expect_response: + return resp + + logger.debug( + "[vspin] Empty response to %s (attempt %d/%d)", + cmd.hex(), + attempt, + retries, + ) + await asyncio.sleep(0.15) + + raise TimeoutError(f"No response to VSpin command {cmd.hex()}") + + @staticmethod + def _expected_response_len(cmd: bytes) -> Optional[int]: + if cmd == bytes.fromhex("aa010e0f"): + return 14 + if cmd == bytes.fromhex("aa020e10"): + return 5 + if cmd in (bytes.fromhex("aa002101ff21"), bytes.fromhex("aa002102ff22")): + return 2 + if cmd in (bytes.fromhex("aa01132034"), bytes.fromhex("aa02132035")): + return 4 + return None + + def _get_command_lock(self) -> asyncio.Lock: + if self._command_lock is None: + self._command_lock = asyncio.Lock() + return self._command_lock + + async def _send_command( + self, + cmd: bytes, + read_timeout: float = DEFAULT_READ_TIMEOUT, + expected_len: Optional[int] = None, + ) -> bytes: + cmd = _with_vspin_checksum(bytes(cmd)) + expected_len = expected_len or self._expected_response_len(cmd) + lock = self._get_command_lock() + + logger.debug("[vspin] Sending %s", cmd.hex()) + async with lock: + written = await self.io.write(cmd) + if written != len(cmd): + raise RuntimeError(f"Failed to write all bytes ({written}/{len(cmd)} bytes written)") + resp = await self._read_resp(timeout=read_timeout, expected_len=expected_len) + + logger.debug("[vspin] Response %s", resp.hex()) + return resp + + async def _startup_handshake(self) -> None: + await self._send_safe(bytes.fromhex("aa002101ff21"), expected_len=2) + await self._send_safe(bytes.fromhex("aa01132034"), expected_len=4) + await self._send_safe(bytes.fromhex("aa002102ff22"), expected_len=2) + await self._send_safe(bytes.fromhex("aa02132035"), expected_len=4) + + # The original software writes this and then tolerates silence for roughly + # two seconds while the controller transitions into its startup state. + await self._send_safe( + bytes.fromhex("aa002103ff23"), + timeout=0.15, + expect_response=False, + ) + await self._drain_startup_silence(2.0) + + await self._send_safe(bytes.fromhex("aaff1a142d"), timeout=0.12, expect_response=False) + await self._send_safe( + bytes.fromhex("aa010e0f"), + timeout=0.30, + expected_len=None, + expect_response=False, + ) + await self._send_safe( + bytes.fromhex("aa020e10"), + timeout=0.30, + expected_len=5, + expect_response=False, + ) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + async def _drain_startup_silence(self, seconds: float) -> None: + end = time.monotonic() + seconds + while time.monotonic() < end: + await self._read_resp(timeout=0.08, expected_len=None, quiet_time=0.01) + await asyncio.sleep(0.03) + + async def _enable_telemetry_and_pneumatics(self) -> None: + await self._send_safe(bytes.fromhex("aa01121f32"), timeout=0.35, expected_len=14) + + for _ in range(8): + await self._send_safe(bytes.fromhex("aa0220ff0f30"), timeout=0.12) + + for cmd in ( + bytes.fromhex("aa0220df0f10"), + bytes.fromhex("aa0220df0e0f"), + bytes.fromhex("aa0220df0c0d"), + bytes.fromhex("aa0220df0809"), + ): + await self._send_safe(cmd, timeout=0.12) + + for _ in range(4): + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.12) + + await self._send_safe(bytes.fromhex("aa02120317"), timeout=0.12) + + for _ in range(5): + await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.15) + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) + + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) + await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.15) + await self._poll_io_status(0.35) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) + await self._poll_io_status(0.35) + self._motion_is_prepared = True + + async def _poll_io_status(self, seconds: float) -> None: + end = time.monotonic() + seconds + while time.monotonic() < end: + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.10, expected_len=5) + await asyncio.sleep(0.04) + + async def _motor_enable(self) -> None: + await self._send_safe(bytes.fromhex("aa0117021a"), timeout=0.30, expected_len=14) + await self._send_safe( + bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), + timeout=0.30, + expected_len=14, + ) + await self._send_safe(bytes.fromhex("aa0117041c"), timeout=0.30, expected_len=14) + await self._send_safe(bytes.fromhex("aa01170119"), timeout=0.30, expected_len=14) + await self._send_safe(bytes.fromhex("aa010b0c"), timeout=0.30, expected_len=14) + + async def _home_rotor(self) -> None: + await self._motor_enable() + await self._send_safe(bytes.fromhex("aa010001"), timeout=0.30, expected_len=14) + await self._send_safe( + bytes.fromhex("aa01e605006400000000003200e80301006e"), + timeout=0.30, + expected_len=14, + ) + await self._send_safe( + bytes.fromhex("aa0194b61283000012010000f3"), + timeout=0.30, + expected_len=14, + ) + await self._send_safe(bytes.fromhex("aa01192842"), timeout=0.30, expected_len=14) + + status = await self._wait_for_idle(label="homing", timeout=35.0) + if status.home_position == 0: + await self._send_safe( + bytes.fromhex("aa01121f32"), + timeout=0.35, + expected_len=14, + expect_response=False, + ) + status = await self._wait_for_full_status(timeout=5.0) + + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + + async def _wait_for_full_status(self, timeout: float) -> _StatusPositionTachometer: + end = time.monotonic() + timeout + last_raw = b"" + while time.monotonic() < end: + resp = await self._send_command( + bytes.fromhex("aa010e0f"), + read_timeout=0.40, + expected_len=14, + ) + last_raw = resp + status = self._find_status_packet(resp) + if status is not None and status.home_position != 0: + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + return status + await asyncio.sleep(0.10) + + raise TimeoutError( + "VSpin homing reached idle, but no full 14-byte status packet with a " + f"home position was received. Last raw response: {last_raw.hex() or '(empty)'}" + ) + + async def _wait_for_idle( + self, + label: str, + timeout: float, + target_position: Optional[int] = None, + tolerance: int = POSITION_TOLERANCE, + ) -> _StatusPositionTachometer: + start = time.monotonic() + last_status = self._make_status( + 0x19, + self._last_position, + home_position=self._last_home_position, + ) + + while time.monotonic() - start <= timeout: + status = await self._get_positions_and_tachometer() + last_status = status + is_idle_status = status.status in (0x09, 0x11, 0x91) + is_stopped = abs(status.tachometer) <= 2 + is_at_target = ( + target_position is None + or abs(int(status.current_position) - int(target_position)) <= tolerance + ) + + if is_idle_status and is_stopped and is_at_target: + return status + + await asyncio.sleep(STATUS_POLL_INTERVAL) + + raise TimeoutError( + f"VSpin {label} did not become idle within {timeout:.1f}s " + f"(status=0x{last_status.status:02x}, position={last_status.current_position}, " + f"tachometer={last_status.tachometer}, home={last_status.home_position})" + ) + + async def _prepare_bucket_motion(self) -> None: + await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.20) + await self._poll_io_status(0.25) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) + await self._poll_io_status(0.25) + self._motion_is_prepared = True + + async def _prepare_spin_motion(self) -> None: + await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.20) + await self._poll_io_status(0.40) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) + await self._poll_io_status(0.30) + self._motion_is_prepared = True + + async def _send_deceleration(self, deceleration: float) -> None: + await self._send_safe( + bytes.fromhex("aa01e60500640000000000fd00803e01000c"), + timeout=0.25, + ) + await self._send_safe(_build_vspin_deceleration_command(deceleration), timeout=0.25) + + async def _wait_for_door(self, open_expected: bool, timeout: float) -> None: + end = time.monotonic() + timeout + while time.monotonic() < end: + try: + if await self.get_door_open() is open_expected: + return + except IOError: + pass + await asyncio.sleep(0.12) + + expected = "open" if open_expected else "closed" + raise TimeoutError(f"VSpin door did not report {expected} within {timeout:.1f}s") + + async def _wait_for_speed_or_motion(self, rpm: int, final_position: int) -> None: + deadline = time.monotonic() + 25.0 + while time.monotonic() < deadline and not self._stop_requested: + status = await self._get_positions_and_tachometer() + live_rpm = status.tachometer * TACH_TO_RPM + if live_rpm >= rpm * 0.92: + return + if status.current_position >= final_position: + return + await asyncio.sleep(0.25) + + async def _hold_spin(self, duration: float) -> None: + started = time.monotonic() + while not self._stop_requested and time.monotonic() - started < duration: + await self._get_positions_and_tachometer() + await asyncio.sleep(min(1.0, duration)) + + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() + + async def set_configuration_data(self): + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) + + async def initialize(self): + for _ in range(2): + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e"), read_timeout=0.08) + + # Centrifuge operations + + async def open_door(self): + try: + if await self.get_door_open(): + return + except IOError: + pass + + await self._send_safe(bytes.fromhex("aa022600072f"), timeout=0.30) + await self._wait_for_door(open_expected=True, timeout=4.0) + + async def close_door(self): + try: + if not await self.get_door_open(): + return + except IOError: + pass + + await self._send_safe(bytes.fromhex("aa022600052d"), timeout=0.30) + await self._wait_for_door(open_expected=False, timeout=4.0) + self._motion_is_prepared = False + + async def lock_door(self): + if await self.get_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.get_door_locked(): + return + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) + + async def unlock_door(self): + if not await self.get_door_locked(): + return + await self._send_safe(bytes.fromhex("aa022600042c"), timeout=0.20) + + async def lock_bucket(self): + if await self.get_bucket_locked(): + return + await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.25) + await self._poll_io_status(0.35) + self._motion_is_prepared = False + + async def unlock_bucket(self): + if not await self.get_bucket_locked(): + return + await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.25) + await self._poll_io_status(0.25) + self._motion_is_prepared = True + + async def go_to_bucket1(self): + await self.go_to_position(await self._get_bucket_position(1)) + + async def go_to_bucket2(self): + await self.go_to_position(await self._get_bucket_position(2)) + + async def go_to_position(self, position: int): + position = int(position) + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if not self._motion_is_prepared: + await self._prepare_bucket_motion() + + await self._motor_enable() + await self._send_safe( + bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), + timeout=0.20, + expected_len=14, + ) + await self._send_safe( + _build_vspin_position_command(position), + timeout=0.25, + expected_len=14, + ) + await self._wait_for_idle( + label=f"position {position}", + timeout=25.0, + target_position=position, + tolerance=10, + ) + await self.lock_bucket() + await self.open_door() + + @staticmethod + def g_to_rpm(g: float) -> int: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) + return rpm + + async def spin( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + """Start a spin cycle. spin spin spin spin + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + acceleration: 0-1 of total acceleration + deceleration: 0-1 of total deceleration + """ + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + # 1 - compute the final position + rpm = V11VSpinBackend.g_to_rpm(g) + self._stop_requested = False + + try: + await self._prepare_spin_motion() + await self._motor_enable() + await self._send_safe( + bytes.fromhex("aa01e60500640000000000fd00803e01000c"), + timeout=0.25, + expected_len=14, + ) + + current_position = await self.get_position() + spin_command, final_position = _build_vspin_spin_command( + current_position=current_position, + rpm=rpm, + duration=duration, + acceleration=acceleration, + ) + await self._send_safe(spin_command, timeout=0.25, expected_len=14) + + await self._wait_for_speed_or_motion(rpm=rpm, final_position=final_position) + await self._hold_spin(duration) + await self._send_deceleration(deceleration) + except asyncio.CancelledError: + await self._send_deceleration(deceleration) + raise + except Exception: + logger.exception("[vspin] Spin failed; attempting deceleration") + try: + await self._send_deceleration(deceleration) + except Exception: + logger.exception("[vspin] Deceleration after failed spin also failed") + raise + finally: + self._stop_requested = False + + await self._wait_for_idle(label="spin rundown", timeout=90.0) + await self._home_rotor() diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index 38f4102ad0e..0d7e3b4306e 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -658,6 +658,27 @@ async def _reset_to_zero(): raise RuntimeError("Home position did not change after spin.") +def create_vspin_backend( + device_id: Optional[str] = None, + variant: str = "agilent", +) -> CentrifugeBackend: + """Create a VSpin backend for the selected centrifuge generation. + + ``agilent`` keeps the existing PyLabRobot command path for newer + Agilent-branded VSpin centrifuges. ``v11``/``velocity11`` selects the legacy + Velocity11 path derived from old V11 hardware traces. The command sets should + not be treated as interchangeable until both generations are hardware-tested. + """ + normalized_variant = variant.lower() + if normalized_variant == "agilent": + return VSpinBackend(device_id=device_id) + if normalized_variant in ("v11", "velocity11", "legacy"): + from .v11_vspin_backend import V11VSpinBackend + + return V11VSpinBackend(device_id=device_id) + raise ValueError("variant must be 'agilent', 'v11', 'velocity11', or 'legacy'") + + # Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) # https://github.com/PyLabRobot/pylabrobot/issues/466 diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py new file mode 100644 index 00000000000..c7ca996a8ab --- /dev/null +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -0,0 +1,140 @@ +import unittest +from unittest import mock + +from pylabrobot.centrifuge.v11_vspin_backend import ( + DEFAULT_BUCKET_1_REMAINDER, + V11VSpinBackend, + _build_vspin_deceleration_command, +) +from pylabrobot.centrifuge.vspin_backend import VSpinBackend, create_vspin_backend + + +class _FakeIO: + def __init__(self, read_chunks): + self.read_chunks = list(read_chunks) + self.writes = [] + + async def read(self, num_bytes: int) -> bytes: + if self.read_chunks: + return self.read_chunks.pop(0) + return b"" + + async def write(self, data: bytes) -> int: + self.writes.append(data) + return len(data) + + +def _make_backend(io: _FakeIO) -> V11VSpinBackend: + backend = object.__new__(V11VSpinBackend) + backend.io = io + backend._command_lock = None + backend._last_position = 0 + backend._last_home_position = 0 + backend._bucket_1_remainder = DEFAULT_BUCKET_1_REMAINDER + backend._motion_is_prepared = False + backend._stop_requested = False + return backend + + +def _status_packet( + status: int = 0x11, + current_position: int = 12070, + tachometer: int = -10, + home_position: int = 6733, +) -> bytes: + packet = ( + bytes([status]) + + current_position.to_bytes(4, "little") + + b"\x4f" + + tachometer.to_bytes(2, "little", signed=True) + + b"\x18" + + home_position.to_bytes(4, "little") + ) + return packet + bytes([sum(packet) & 0xFF]) + + +class V11VSpinBackendTests(unittest.IsolatedAsyncioTestCase): + async def test_read_resp_returns_expected_binary_packet_without_cr(self): + backend = _make_backend(_FakeIO([b"\x00\x30", b"\x08\x30\x68"])) + + resp = await backend._read_resp(timeout=1.0, expected_len=5) + + self.assertEqual(resp, bytes.fromhex("0030083068")) + + async def test_send_command_repairs_checksum_and_uses_expected_length(self): + io = _FakeIO([bytes.fromhex("0030083068")]) + backend = _make_backend(io) + + resp = await backend._send_command(bytes.fromhex("aa020e00")) + + self.assertEqual(resp, bytes.fromhex("0030083068")) + self.assertEqual(io.writes, [bytes.fromhex("aa020e10")]) + + def test_find_status_packet_scans_noise_and_validates_checksum(self): + packet = _status_packet() + + parsed = V11VSpinBackend._find_status_packet(b"\x00\xff" + packet + b"\x00") + + assert parsed is not None + self.assertEqual(parsed.status, 0x11) + self.assertEqual(parsed.current_position, 12070) + self.assertEqual(parsed.tachometer, -10) + self.assertEqual(parsed.home_position, 6733) + + def test_find_status_packet_rejects_bad_checksum(self): + packet = bytearray(_status_packet()) + packet[-1] ^= 0xFF + + self.assertIsNone(V11VSpinBackend._find_status_packet(bytes(packet))) + + def test_find_short_status_from_io_packet(self): + self.assertEqual(V11VSpinBackend._find_short_status(bytes.fromhex("0030083068")), 0x08) + + async def test_default_bucket_positions_follow_home_offset(self): + backend = _make_backend(_FakeIO([])) + + async def get_home_position() -> int: + return 6733 + + async def get_position() -> int: + return 7000 + + backend.get_home_position = get_home_position + backend.get_position = get_position + + self.assertEqual(await backend._get_bucket_position(1), 12070) + self.assertEqual(await backend._get_bucket_position(2), 16070) + + async def test_bucket_2_position_does_not_skip_when_bucket_1_is_behind_current_position(self): + backend = _make_backend(_FakeIO([])) + + async def get_home_position() -> int: + return 6733 + + async def get_position() -> int: + return 14000 + + backend.get_home_position = get_home_position + backend.get_position = get_position + + self.assertEqual(await backend._get_bucket_position(2), 16070) + + def test_deceleration_command_uses_observed_checksum(self): + self.assertEqual( + _build_vspin_deceleration_command(0.8), + bytes.fromhex("aa0194b600000000dc02000029"), + ) + + +class VSpinBackendSelectionTests(unittest.TestCase): + def test_factory_defaults_to_agilent_backend(self): + with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): + backend = create_vspin_backend() + + self.assertIsInstance(backend, VSpinBackend) + + def test_factory_can_select_legacy_v11_backend(self): + with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.FTDI"): + backend = create_vspin_backend(variant="v11") + + self.assertIsInstance(backend, V11VSpinBackend) From a0ee305ba0a0cc4d5130eb648b790ed1d346c88d Mon Sep 17 00:00:00 2001 From: TheRealDarkLuke Date: Sat, 27 Jun 2026 09:52:00 +0200 Subject: [PATCH 02/22] Update VSpin documentation links --- README.md | 8 ++-- .../centrifuge/agilent_vspin.ipynb | 39 ++++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 675f8b31cb7..fc0b3e92291 100644 --- a/README.md +++ b/README.md @@ -83,20 +83,22 @@ data = await pr.read_luminescence() For Cytation5, use the `Cytation5` backend. -### Centrifuges ([docs](https://docs.pylabrobot.org/user_guide/01_material-handling/centrifuge/_centrifuge.html)) +### Centrifuges ([docs](https://docs.pylabrobot.org/0.2.1/user_guide/01_material-handling/centrifuge/_centrifuge.html)) Centrifugation at 800g for 60 seconds with an Agilent VSpin: ```python -from pylabrobot.centrifuge import Centrifuge, VSpinBackend +from pylabrobot.centrifuge import Centrifuge, create_vspin_backend -vspin_backend = VSpinBackend(device_id="YOUR_FTDI_ID_HERE") +vspin_backend = create_vspin_backend(device_id="YOUR_FTDI_ID_HERE", variant="agilent") cf = Centrifuge(name="centrifuge", backend=vspin_backend, size_x=1, size_y=1, size_z=1) await cf.setup() await cf.spin(g=800, duration=60) ``` +Use `variant="v11"` for legacy Velocity11 VSpin centrifuges. The legacy backend has not yet been validated on hardware in PLR. + For a HighRes Biosolutions MicroSpin, use the MicroSpin factory: ```python diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb index e0ab2cc16b8..82874643fbf 100644 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb +++ b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb @@ -7,7 +7,9 @@ "source": [ "# Agilent VSpin\n", "\n", - "The VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class." + "The Agilent-branded VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. It remains the default backend for newer Agilent systems.\n", + "\n", + "Legacy Velocity11 units can use the {class}`~pylabrobot.centrifuge.v11_vspin_backend.V11VSpinBackend` class or the {func}`~pylabrobot.centrifuge.vspin_backend.create_vspin_backend` helper with `variant=\"v11\"`. The Velocity11 backend was ported from working code, but it has not yet been validated on hardware in PLR." ] }, { @@ -17,12 +19,37 @@ "metadata": {}, "outputs": [], "source": [ - "from pylabrobot.centrifuge import Centrifuge, VSpinBackend\n", - "vspin_backend = VSpinBackend() # VSpinBackend(device_id=\"YOUR_FTDI_ID_HERE\")\n", - "cf = Centrifuge(name = \"centrifuge\", backend = vspin_backend, size_x= 1, size_y=1, size_z=1)\n", + "from pylabrobot.centrifuge import Centrifuge, create_vspin_backend\n", + "\n", + "vspin_backend = create_vspin_backend(\n", + " variant=\"agilent\",\n", + " device_id=\"YOUR_FTDI_ID_HERE\",\n", + ")\n", + "cf = Centrifuge(name=\"centrifuge\", backend=vspin_backend, size_x=1, size_y=1, size_z=1)\n", "await cf.setup()" ] }, + { + "cell_type": "markdown", + "id": "5ac0a64e", + "metadata": {}, + "source": [ + "For a legacy Velocity11 VSpin, keep the same `Centrifuge` setup and switch the backend variant:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c55a2ee0", + "metadata": {}, + "outputs": [], + "source": [ + "legacy_vspin_backend = create_vspin_backend(\n", + " variant=\"v11\",\n", + " device_id=\"YOUR_FTDI_ID_HERE\",\n", + ")" + ] + }, { "cell_type": "markdown", "id": "e5ee122c", @@ -217,8 +244,8 @@ "source": [ "import asyncio\n", "\n", - "from pylabrobot.centrifuge import Access2, VSpinBackend\n", - "vspin_backend = VSpinBackend(device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\n", + "from pylabrobot.centrifuge import Access2, create_vspin_backend\n", + "vspin_backend = create_vspin_backend(variant=\"agilent\", device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\n", "centrifuge, loader = Access2(name=\"name\", vspin=vspin_backend, device_id=\"YOUR_LOADER_FTDI_ID_HERE\")\n", "\n", "# initialize the centrifuge and loader in parallel\n", From b6a0e7edc4e70a0f0715654ecb28ce9e6d062681 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Tue, 30 Jun 2026 17:31:18 +0200 Subject: [PATCH 03/22] Improve Velocity11 VSpin hardware support Add a pyserial-backed FTDI path for Windows COM-port access, harden the V11 startup and runtime attach flow, and make homing wait for real controller activity before accepting idle status. Validated with the local VSpin hardware workflow at 300 g for 10 seconds. --- pylabrobot/centrifuge/v11_vspin_backend.py | 306 +++++++++++++++--- pylabrobot/centrifuge/vspin_backend_tests.py | 323 ++++++++++++++++++- pylabrobot/io/ftdi.py | 243 ++++++++++++-- pyproject.toml | 2 +- 4 files changed, 807 insertions(+), 67 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index fdf9f5be47f..9228cf4150c 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -50,11 +50,17 @@ def _save_vspin_calibrations(device_id, remainder: int): DEFAULT_BUCKET_1_OFFSET: int = 5337 DEFAULT_BUCKET_1_REMAINDER: int = -DEFAULT_BUCKET_1_OFFSET DEFAULT_READ_TIMEOUT: float = 0.2 -STATUS_POLL_INTERVAL: float = 0.08 +COMMAND_GAP_SECONDS: float = 0.05 +CONTROLLER_CONNECT_SETTLE_SECONDS: float = 2.0 +INITIALIZE_PACKET_GAP_SECONDS: float = 0.01 +STATUS_POLL_INTERVAL: float = 0.15 POSITION_TOLERANCE: int = 15 +POSITION_SETTLE_TOLERANCE: int = 200 TACH_TO_RPM: float = -14.69320388 +DOOR_OPEN_SETTLE_SECONDS: float = 2.0 -_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} +_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x0B, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} +_IDLE_VSPIN_STATUSES = {0x09, 0x0B, 0x11, 0x89, 0x91} def _with_vspin_checksum(cmd: bytes) -> bytes: @@ -107,6 +113,20 @@ def _build_vspin_deceleration_command(deceleration: float) -> bytes: ) +def _normalize_vspin_home_position(home_position: int) -> int: + return int(home_position) % FULL_ROTATION + + +def _vspin_position_matches_target(position: int, target: int, tolerance: int) -> bool: + absolute_delta = abs(int(position) - int(target)) + if absolute_delta <= tolerance: + return True + + angular_delta = abs((int(position) % FULL_ROTATION) - (int(target) % FULL_ROTATION)) + angular_delta = min(angular_delta, FULL_ROTATION - angular_delta) + return angular_delta <= tolerance + + bucket_1_not_set_error = RuntimeError( "Bucket 1 position not set. " "Please rotate the bucket to bucket 1 using V11VSpinBackend.go_to_position and " @@ -126,38 +146,79 @@ class V11VSpinBackend(CentrifugeBackend): hardware before relying on it in production. """ - def __init__(self, device_id: Optional[str] = None): + def __init__( + self, + device_id: Optional[str] = None, + try_runtime_attach_after_startup_failure: bool = False, + ): """ Args: device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices`. + try_runtime_attach_after_startup_failure: Try to attach to a controller + that is already in 57600-baud runtime mode before cold startup, and + retry that attach path if the normal 19200-baud startup handshake + fails. This is disabled by default because some + legacy controllers are sensitive to extra startup probes. """ self.io = FTDI(human_readable_device_name="Velocity11 VSpin Centrifuge", device_id=device_id) self._bucket_1_remainder: Optional[int] = DEFAULT_BUCKET_1_REMAINDER self._last_position: int = 0 self._last_home_position: int = 0 + self._home_sensor_position: Optional[int] = None self._motion_is_prepared = False self._stop_requested = False self._command_lock: Optional[asyncio.Lock] = None + self._last_command_at = 0.0 + self._try_runtime_attach_after_startup_failure = try_runtime_attach_after_startup_failure # only attempt loading calibration if device_id is not None # if it is None, we will load it after setup when we can query the device id from the io if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) or DEFAULT_BUCKET_1_REMAINDER + calibration = _load_vspin_calibrations(device_id) + self._bucket_1_remainder = ( + calibration if calibration is not None else DEFAULT_BUCKET_1_REMAINDER + ) async def setup(self): await self.io.setup() - self._command_lock = asyncio.Lock() - self._motion_is_prepared = False - - await self.configure_and_initialize() - await self._startup_handshake() - await self._enable_telemetry_and_pneumatics() - await self._home_rotor() - - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder == DEFAULT_BUCKET_1_REMAINDER: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) or DEFAULT_BUCKET_1_REMAINDER + try: + self._command_lock = asyncio.Lock() + self._motion_is_prepared = False + + attached_to_runtime = False + if self._try_runtime_attach_after_startup_failure: + attached_to_runtime = await self._try_attach_to_runtime_controller() + if attached_to_runtime: + logger.info("[vspin] Attached to runtime controller before cold startup") + + if not attached_to_runtime: + await self.configure_and_initialize() + try: + await self._startup_handshake() + await self._enable_telemetry_and_pneumatics() + except TimeoutError as e: + if ( + self._try_runtime_attach_after_startup_failure + and await self._try_attach_to_runtime_controller() + ): + logger.info("[vspin] Recovered controller after partial startup") + else: + raise TimeoutError( + "VSpin did not respond to the 19200-baud startup handshake. " + "Power-cycle or restart the VSpin controller, then try setup again." + ) from e + + await self._home_rotor() + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder == DEFAULT_BUCKET_1_REMAINDER: + device_id = await self.io.get_serial() + calibration = _load_vspin_calibrations(device_id) + self._bucket_1_remainder = ( + calibration if calibration is not None else DEFAULT_BUCKET_1_REMAINDER + ) + except Exception: + await self._close_connection_cleanly() + raise @property def bucket_1_remainder(self) -> int: @@ -169,7 +230,15 @@ async def set_bucket_1_position_to_current(self) -> None: """Set the current position as bucket 1 position and save calibration.""" current_position = await self.get_position() device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position + home_sensor_position = ( + self._home_sensor_position + if self._home_sensor_position is not None + else await self.get_home_position() + ) + home_sensor_position = _normalize_vspin_home_position(home_sensor_position) + home_rotations = (current_position - home_sensor_position) // FULL_ROTATION + home_position = home_sensor_position + home_rotations * FULL_ROTATION + remainder = home_position - current_position self._bucket_1_remainder = remainder _save_vspin_calibrations(device_id, remainder) @@ -186,7 +255,19 @@ async def _get_bucket_position(self, bucket_num: int) -> int: raise ValueError("bucket_num must be 1 or 2") if self._bucket_1_remainder is None: raise bucket_1_not_set_error - home_position = await self.get_home_position() + + home_position = self._home_sensor_position + if home_position is None: + live_home_position = await self.get_home_position() + if live_home_position == 0: + await self._home_rotor() + home_position = self._home_sensor_position + else: + home_position = live_home_position + + if home_position is None: + raise RuntimeError("VSpin home sensor position is unknown. Run setup or _home_rotor first.") + current_position = await self.get_position() target_position = home_position - self.bucket_1_remainder if bucket_num == 2: @@ -198,8 +279,7 @@ async def _get_bucket_position(self, bucket_num: int) -> int: return target_position async def stop(self): - await self.configure_and_initialize() - await self.io.stop() + await self._close_connection_cleanly() class _StatusPositionTachometer(ctypes.LittleEndianStructure): _pack_ = 1 @@ -437,19 +517,23 @@ async def _send_command( logger.debug("[vspin] Sending %s", cmd.hex()) async with lock: + command_gap = COMMAND_GAP_SECONDS - (time.monotonic() - self._last_command_at) + if command_gap > 0: + await asyncio.sleep(command_gap) written = await self.io.write(cmd) if written != len(cmd): raise RuntimeError(f"Failed to write all bytes ({written}/{len(cmd)} bytes written)") resp = await self._read_resp(timeout=read_timeout, expected_len=expected_len) + self._last_command_at = time.monotonic() logger.debug("[vspin] Response %s", resp.hex()) return resp async def _startup_handshake(self) -> None: - await self._send_safe(bytes.fromhex("aa002101ff21"), expected_len=2) - await self._send_safe(bytes.fromhex("aa01132034"), expected_len=4) - await self._send_safe(bytes.fromhex("aa002102ff22"), expected_len=2) - await self._send_safe(bytes.fromhex("aa02132035"), expected_len=4) + await self._send_safe(bytes.fromhex("aa002101ff21"), timeout=0.60, expected_len=2) + await self._send_safe(bytes.fromhex("aa01132034"), timeout=0.60, expected_len=4) + await self._send_safe(bytes.fromhex("aa002102ff22"), timeout=0.60, expected_len=2) + await self._send_safe(bytes.fromhex("aa02132035"), timeout=0.60, expected_len=4) # The original software writes this and then tolerates silence for roughly # two seconds while the controller transitions into its startup state. @@ -484,6 +568,40 @@ async def _drain_startup_silence(self, seconds: float) -> None: await self._read_resp(timeout=0.08, expected_len=None, quiet_time=0.01) await asyncio.sleep(0.03) + async def _try_attach_to_runtime_controller(self) -> bool: + """Attach to a VSpin controller that is already in 57600-baud runtime mode.""" + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + await self._purge_io_buffers() + await self._drain_startup_silence(0.25) + + for _ in range(2): + for status_command in ( + bytes.fromhex("aa010e0f"), + bytes.fromhex("aa01121f32"), + ): + resp = await self._send_command( + status_command, + read_timeout=0.40, + expected_len=14, + ) + status = self._find_status_packet(resp) + if status is not None: + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + logger.info( + "[vspin] Attached to runtime controller " + "(status=0x%02x, position=%d, home=%d)", + status.status, + status.current_position, + status.home_position, + ) + return True + await asyncio.sleep(0.20) + + return False + async def _enable_telemetry_and_pneumatics(self) -> None: await self._send_safe(bytes.fromhex("aa01121f32"), timeout=0.35, expected_len=14) @@ -520,7 +638,7 @@ async def _poll_io_status(self, seconds: float) -> None: end = time.monotonic() + seconds while time.monotonic() < end: await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.10, expected_len=5) - await asyncio.sleep(0.04) + await asyncio.sleep(0.12) async def _motor_enable(self) -> None: await self._send_safe(bytes.fromhex("aa0117021a"), timeout=0.30, expected_len=14) @@ -534,6 +652,7 @@ async def _motor_enable(self) -> None: await self._send_safe(bytes.fromhex("aa010b0c"), timeout=0.30, expected_len=14) async def _home_rotor(self) -> None: + pre_home_status = await self._get_positions_and_tachometer() await self._motor_enable() await self._send_safe(bytes.fromhex("aa010001"), timeout=0.30, expected_len=14) await self._send_safe( @@ -548,7 +667,13 @@ async def _home_rotor(self) -> None: ) await self._send_safe(bytes.fromhex("aa01192842"), timeout=0.30, expected_len=14) - status = await self._wait_for_idle(label="homing", timeout=35.0) + status = await self._wait_for_idle( + label="homing", + timeout=35.0, + min_wait=1.0, + require_activity_from=pre_home_status, + activity_tolerance=POSITION_SETTLE_TOLERANCE, + ) if status.home_position == 0: await self._send_safe( bytes.fromhex("aa01121f32"), @@ -556,14 +681,20 @@ async def _home_rotor(self) -> None: expected_len=14, expect_response=False, ) - status = await self._wait_for_full_status(timeout=5.0) + status = await self._wait_for_full_status(timeout=5.0, allow_zero_home_fallback=True) self._last_position = int(status.current_position) self._last_home_position = int(status.home_position) + self._home_sensor_position = _normalize_vspin_home_position(status.home_position) - async def _wait_for_full_status(self, timeout: float) -> _StatusPositionTachometer: + async def _wait_for_full_status( + self, + timeout: float, + allow_zero_home_fallback: bool = False, + ) -> _StatusPositionTachometer: end = time.monotonic() + timeout last_raw = b"" + last_full_status: Optional[V11VSpinBackend._StatusPositionTachometer] = None while time.monotonic() < end: resp = await self._send_command( bytes.fromhex("aa010e0f"), @@ -576,8 +707,15 @@ async def _wait_for_full_status(self, timeout: float) -> _StatusPositionTachomet self._last_position = int(status.current_position) self._last_home_position = int(status.home_position) return status + if status is not None: + last_full_status = status await asyncio.sleep(0.10) + if allow_zero_home_fallback and last_full_status is not None: + self._last_position = int(last_full_status.current_position) + self._last_home_position = int(last_full_status.home_position) + return last_full_status + raise TimeoutError( "VSpin homing reached idle, but no full 14-byte status packet with a " f"home position was received. Last raw response: {last_raw.hex() or '(empty)'}" @@ -589,6 +727,9 @@ async def _wait_for_idle( timeout: float, target_position: Optional[int] = None, tolerance: int = POSITION_TOLERANCE, + min_wait: float = 0.0, + require_activity_from: Optional[_StatusPositionTachometer] = None, + activity_tolerance: int = POSITION_TOLERANCE, ) -> _StatusPositionTachometer: start = time.monotonic() last_status = self._make_status( @@ -596,22 +737,46 @@ async def _wait_for_idle( self._last_position, home_position=self._last_home_position, ) + observed_activity = require_activity_from is None while time.monotonic() - start <= timeout: status = await self._get_positions_and_tachometer() last_status = status - is_idle_status = status.status in (0x09, 0x11, 0x91) + is_idle_status = status.status in _IDLE_VSPIN_STATUSES is_stopped = abs(status.tachometer) <= 2 + if require_activity_from is not None and not observed_activity: + observed_activity = ( + not is_idle_status + or not is_stopped + or not _vspin_position_matches_target( + position=int(status.current_position), + target=int(require_activity_from.current_position), + tolerance=activity_tolerance, + ) + or ( + int(status.home_position) != 0 + and int(status.home_position) != int(require_activity_from.home_position) + ) + ) is_at_target = ( target_position is None - or abs(int(status.current_position) - int(target_position)) <= tolerance + or _vspin_position_matches_target( + position=int(status.current_position), + target=int(target_position), + tolerance=tolerance, + ) ) - if is_idle_status and is_stopped and is_at_target: + if ( + is_idle_status + and is_stopped + and is_at_target + and observed_activity + and time.monotonic() - start >= min_wait + ): return status await asyncio.sleep(STATUS_POLL_INTERVAL) - raise TimeoutError( f"VSpin {label} did not become idle within {timeout:.1f}s " f"(status=0x{last_status.status:02x}, position={last_status.current_position}, " @@ -671,34 +836,68 @@ async def _hold_spin(self, duration: float) -> None: async def configure_and_initialize(self): await self.set_configuration_data() + await self._settle_controller_connection() await self.initialize() async def set_configuration_data(self): """Set the device configuration data.""" + await self._set_serial_line_defaults() + await self.io.set_baudrate(19200) + + async def _set_serial_line_defaults(self) -> None: await self.io.set_latency_timer(16) await self.io.set_line_property(bits=8, stopbits=1, parity=0) await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + async def _settle_controller_connection(self) -> None: + await asyncio.sleep(CONTROLLER_CONNECT_SETTLE_SECONDS) + + async def _purge_io_buffers(self) -> None: + try: + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + except Exception as e: + logger.debug("[vspin] Ignoring buffer purge during close/setup: %s", e) + + async def _close_connection_cleanly(self) -> None: + try: + await asyncio.sleep(0.20) + await self._purge_io_buffers() + try: + await self.io.set_dtr(False) + await self.io.set_rts(False) + except Exception as e: + logger.debug("[vspin] Ignoring control-line reset during close: %s", e) + await asyncio.sleep(0.20) + finally: + await self.io.stop() async def initialize(self): for _ in range(2): await self.io.write(b"\x00" * 20) + await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) for i in range(33): packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 await self.io.write(packet) + await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) await self._send_command(bytes.fromhex("aaff0f0e"), read_timeout=0.08) + await asyncio.sleep(COMMAND_GAP_SECONDS) # Centrifuge operations async def open_door(self): try: if await self.get_door_open(): + await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) return except IOError: pass await self._send_safe(bytes.fromhex("aa022600072f"), timeout=0.30) await self._wait_for_door(open_expected=True, timeout=4.0) + await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) async def close_door(self): try: @@ -767,7 +966,7 @@ async def go_to_position(self, position: int): label=f"position {position}", timeout=25.0, target_position=position, - tolerance=10, + tolerance=POSITION_SETTLE_TOLERANCE, ) await self.lock_bucket() await self.open_door() @@ -779,6 +978,12 @@ def g_to_rpm(g: float) -> int: rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) return rpm + @staticmethod + def rpm_to_g(rpm: float) -> float: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + return 1.118 * 10**-5 * r * float(rpm) ** 2 + async def spin( self, g: float = 500, @@ -804,6 +1009,37 @@ async def spin( if duration < 1: raise ValueError("Spin time must be at least 1 second") + await self.spin_rpm( + rpm=V11VSpinBackend.g_to_rpm(g), + duration=duration, + acceleration=acceleration, + deceleration=deceleration, + ) + + async def spin_rpm( + self, + rpm: int, + duration: float, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + """Start a spin cycle at a target RPM. + + This is a V11-specific convenience wrapper around the same command path + used by :meth:`spin`. The public PLR centrifuge API remains g-based. + """ + rpm = int(rpm) + duration = float(duration) + + if rpm < 1 or rpm > 3000: + raise ValueError("RPM must be within 1-3000.") + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + if await self.get_door_open(): await self.close_door() if not await self.get_door_locked(): @@ -811,8 +1047,6 @@ async def spin( if await self.get_bucket_locked(): await self.unlock_bucket() - # 1 - compute the final position - rpm = V11VSpinBackend.g_to_rpm(g) self._stop_requested = False try: diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index c7ca996a8ab..8b9a59c10bc 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -3,8 +3,13 @@ from pylabrobot.centrifuge.v11_vspin_backend import ( DEFAULT_BUCKET_1_REMAINDER, + POSITION_SETTLE_TOLERANCE, + POSITION_TOLERANCE, V11VSpinBackend, + _IDLE_VSPIN_STATUSES, _build_vspin_deceleration_command, + _normalize_vspin_home_position, + _vspin_position_matches_target, ) from pylabrobot.centrifuge.vspin_backend import VSpinBackend, create_vspin_backend @@ -24,15 +29,40 @@ async def write(self, data: bytes) -> int: return len(data) +class _RecordingConfigIO: + def __init__(self): + self.calls = [] + + async def set_latency_timer(self, latency: int): + self.calls.append(("set_latency_timer", latency)) + + async def set_line_property(self, bits: int, stopbits: int, parity: int): + self.calls.append(("set_line_property", bits, stopbits, parity)) + + async def set_flowctrl(self, flowctrl: int): + self.calls.append(("set_flowctrl", flowctrl)) + + async def set_rts(self, level: bool): + self.calls.append(("set_rts", level)) + + async def set_dtr(self, level: bool): + self.calls.append(("set_dtr", level)) + + async def set_baudrate(self, baudrate: int): + self.calls.append(("set_baudrate", baudrate)) + + def _make_backend(io: _FakeIO) -> V11VSpinBackend: backend = object.__new__(V11VSpinBackend) backend.io = io backend._command_lock = None backend._last_position = 0 backend._last_home_position = 0 + backend._home_sensor_position = None backend._bucket_1_remainder = DEFAULT_BUCKET_1_REMAINDER backend._motion_is_prepared = False backend._stop_requested = False + backend._last_command_at = 0.0 return backend @@ -90,11 +120,69 @@ def test_find_status_packet_rejects_bad_checksum(self): def test_find_short_status_from_io_packet(self): self.assertEqual(V11VSpinBackend._find_short_status(bytes.fromhex("0030083068")), 0x08) + def test_status_0x89_is_idle_when_stopped_at_target(self): + self.assertIn(0x89, _IDLE_VSPIN_STATUSES) + + def test_home_position_is_normalized_to_one_rotation(self): + self.assertEqual(_normalize_vspin_home_position(839825), 7825) + + def test_position_settle_tolerance_allows_mechanical_stop_variance(self): + self.assertGreaterEqual(POSITION_SETTLE_TOLERANCE, 29) + self.assertGreater(POSITION_SETTLE_TOLERANCE, POSITION_TOLERANCE) + + def test_position_target_matching_handles_rotation_wrap(self): + self.assertTrue( + _vspin_position_matches_target( + position=5395, + target=13258, + tolerance=POSITION_SETTLE_TOLERANCE, + ) + ) + + async def test_wait_for_full_status_can_accept_zero_home_fallback(self): + backend = _make_backend( + _FakeIO( + [ + _status_packet( + status=0x89, + current_position=5548, + tachometer=-1, + home_position=0, + ) + ] + ) + ) + + status = await backend._wait_for_full_status(timeout=0.01, allow_zero_home_fallback=True) + + self.assertEqual(status.home_position, 0) + self.assertEqual(status.current_position, 5548) + + async def test_status_poll_does_not_overwrite_home_sensor_reference(self): + backend = _make_backend( + _FakeIO( + [ + _status_packet( + status=0x09, + current_position=8025, + tachometer=0, + home_position=5312, + ) + ] + ) + ) + backend._home_sensor_position = 2710 + + await backend._get_positions_and_tachometer() + + self.assertEqual(backend._home_sensor_position, 2710) + async def test_default_bucket_positions_follow_home_offset(self): backend = _make_backend(_FakeIO([])) + backend._home_sensor_position = 6733 async def get_home_position() -> int: - return 6733 + return 9999 async def get_position() -> int: return 7000 @@ -107,9 +195,10 @@ async def get_position() -> int: async def test_bucket_2_position_does_not_skip_when_bucket_1_is_behind_current_position(self): backend = _make_backend(_FakeIO([])) + backend._home_sensor_position = 6733 async def get_home_position() -> int: - return 6733 + return 9999 async def get_position() -> int: return 14000 @@ -119,12 +208,242 @@ async def get_position() -> int: self.assertEqual(await backend._get_bucket_position(2), 16070) + async def test_bucket_positions_use_homed_reference_when_live_home_changes(self): + backend = _make_backend(_FakeIO([])) + backend._home_sensor_position = 7936 + + async def get_home_position() -> int: + return 12115 + + async def get_position() -> int: + return 13287 + + backend.get_home_position = get_home_position + backend.get_position = get_position + + self.assertEqual(await backend._get_bucket_position(2), 17273) + def test_deceleration_command_uses_observed_checksum(self): self.assertEqual( _build_vspin_deceleration_command(0.8), bytes.fromhex("aa0194b600000000dc02000029"), ) + def test_rpm_to_g_roundtrips_1500_rpm(self): + g = V11VSpinBackend.rpm_to_g(1500) + + self.assertEqual(V11VSpinBackend.g_to_rpm(g), 1500) + + async def test_spin_uses_rpm_command_path(self): + backend = object.__new__(V11VSpinBackend) + backend.spin_rpm = mock.AsyncMock() + + await V11VSpinBackend.spin( + backend, + g=V11VSpinBackend.rpm_to_g(1500), + duration=10, + acceleration=0.7, + deceleration=0.6, + ) + + backend.spin_rpm.assert_awaited_once_with( + rpm=1500, + duration=10, + acceleration=0.7, + deceleration=0.6, + ) + + async def test_spin_rpm_validates_target_rpm(self): + backend = object.__new__(V11VSpinBackend) + + with self.assertRaises(ValueError): + await V11VSpinBackend.spin_rpm(backend, rpm=0, duration=10) + + async def test_setup_uses_cold_startup_without_runtime_probe_by_default(self): + backend = object.__new__(V11VSpinBackend) + backend.io = mock.Mock() + backend.io.setup = mock.AsyncMock() + backend.io.stop = mock.AsyncMock() + backend.io.usb_purge_rx_buffer = mock.AsyncMock() + backend.io.usb_purge_tx_buffer = mock.AsyncMock() + backend.io.set_dtr = mock.AsyncMock() + backend.io.set_rts = mock.AsyncMock() + backend._bucket_1_remainder = 123 + backend._try_runtime_attach_after_startup_failure = False + events = [] + + async def configure_and_initialize(): + events.append("cold-start") + + async def startup_handshake(): + events.append("startup") + + async def enable_telemetry_and_pneumatics(): + events.append("telemetry") + + async def home_rotor(): + events.append("home") + + backend._try_attach_to_runtime_controller = mock.AsyncMock() + backend.configure_and_initialize = configure_and_initialize + backend._startup_handshake = startup_handshake + backend._enable_telemetry_and_pneumatics = enable_telemetry_and_pneumatics + backend._home_rotor = home_rotor + + await V11VSpinBackend.setup(backend) + + self.assertEqual(events, ["cold-start", "startup", "telemetry", "home"]) + backend._try_attach_to_runtime_controller.assert_not_awaited() + + async def test_setup_closes_io_with_clear_error_when_controller_never_responds(self): + backend = object.__new__(V11VSpinBackend) + backend.io = mock.Mock() + backend.io.setup = mock.AsyncMock() + backend.io.stop = mock.AsyncMock() + backend.io.usb_purge_rx_buffer = mock.AsyncMock() + backend.io.usb_purge_tx_buffer = mock.AsyncMock() + backend.io.set_dtr = mock.AsyncMock() + backend.io.set_rts = mock.AsyncMock() + backend._bucket_1_remainder = 123 + backend._try_runtime_attach_after_startup_failure = False + + backend._try_attach_to_runtime_controller = mock.AsyncMock(return_value=False) + backend.configure_and_initialize = mock.AsyncMock() + backend._startup_handshake = mock.AsyncMock(side_effect=TimeoutError("empty")) + backend._enable_telemetry_and_pneumatics = mock.AsyncMock() + backend._home_rotor = mock.AsyncMock() + + with self.assertRaisesRegex(TimeoutError, "Power-cycle or restart"): + await V11VSpinBackend.setup(backend) + + backend._try_attach_to_runtime_controller.assert_not_awaited() + backend.io.stop.assert_awaited_once() + + async def test_setup_can_optionally_recover_with_runtime_attach_after_startup_failure(self): + backend = object.__new__(V11VSpinBackend) + backend.io = mock.Mock() + backend.io.setup = mock.AsyncMock() + backend.io.stop = mock.AsyncMock() + backend._bucket_1_remainder = 123 + backend._try_runtime_attach_after_startup_failure = True + events = [] + + async def configure_and_initialize(): + events.append("cold-start") + + async def startup_handshake(): + events.append("startup") + raise TimeoutError("empty") + + attach_results = [False, True] + + async def try_attach(): + events.append("attach") + return attach_results.pop(0) + async def home_rotor(): + events.append("home") + + backend.configure_and_initialize = configure_and_initialize + backend._startup_handshake = startup_handshake + backend._enable_telemetry_and_pneumatics = mock.AsyncMock() + backend._try_attach_to_runtime_controller = try_attach + backend._home_rotor = home_rotor + + await V11VSpinBackend.setup(backend) + + self.assertEqual(events, ["attach", "cold-start", "startup", "attach", "home"]) + + async def test_setup_tries_runtime_attach_before_cold_start_when_enabled(self): + backend = object.__new__(V11VSpinBackend) + backend.io = mock.Mock() + backend.io.setup = mock.AsyncMock() + backend.io.get_serial = mock.AsyncMock(return_value="TEST") + backend._bucket_1_remainder = 123 + backend._try_runtime_attach_after_startup_failure = True + backend._command_lock = None + backend._motion_is_prepared = False + backend._try_attach_to_runtime_controller = mock.AsyncMock(return_value=True) + backend.configure_and_initialize = mock.AsyncMock() + backend._startup_handshake = mock.AsyncMock() + backend._enable_telemetry_and_pneumatics = mock.AsyncMock() + backend._home_rotor = mock.AsyncMock() + + await V11VSpinBackend.setup(backend) + + backend._try_attach_to_runtime_controller.assert_awaited_once() + backend.configure_and_initialize.assert_not_awaited() + backend._startup_handshake.assert_not_awaited() + backend._enable_telemetry_and_pneumatics.assert_not_awaited() + backend._home_rotor.assert_awaited_once() + + async def test_runtime_attach_tries_multiple_status_commands(self): + backend = _make_backend(_FakeIO([])) + backend.io.set_baudrate = mock.AsyncMock() + backend.io.set_rts = mock.AsyncMock() + backend.io.set_dtr = mock.AsyncMock() + backend._purge_io_buffers = mock.AsyncMock() + backend._drain_startup_silence = mock.AsyncMock() + commands = [] + + async def send_command(cmd: bytes, read_timeout: float, expected_len: int): + commands.append(cmd) + if cmd == bytes.fromhex("aa01121f32"): + return _status_packet() + return b"" + + backend._send_command = send_command + + attached = await backend._try_attach_to_runtime_controller() + + self.assertTrue(attached) + self.assertEqual( + commands, + [ + bytes.fromhex("aa010e0f"), + bytes.fromhex("aa01121f32"), + ], + ) + self.assertEqual(backend._last_position, 12070) + self.assertEqual(backend._last_home_position, 6733) + + async def test_wait_for_idle_can_reject_stale_idle_before_homing_activity(self): + stale_status = _status_packet( + status=0x09, + current_position=1000, + tachometer=0, + home_position=250, + ) + backend = _make_backend(_FakeIO([stale_status])) + backend._last_position = 1000 + backend._last_home_position = 250 + reference = backend._make_status(0x09, 1000, home_position=250) + + with self.assertRaisesRegex(TimeoutError, "homing"): + await backend._wait_for_idle( + label="homing", + timeout=0.01, + require_activity_from=reference, + activity_tolerance=POSITION_SETTLE_TOLERANCE, + ) + + async def test_configuration_reasserts_control_lines_before_startup_baud(self): + io = _RecordingConfigIO() + backend = _make_backend(io) # type: ignore[arg-type] + + await backend.set_configuration_data() + + self.assertEqual( + io.calls, + [ + ("set_latency_timer", 16), + ("set_line_property", 8, 1, 0), + ("set_flowctrl", 0), + ("set_rts", True), + ("set_dtr", True), + ("set_baudrate", 19200), + ], + ) + class VSpinBackendSelectionTests(unittest.TestCase): def test_factory_defaults_to_agilent_backend(self): diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index 7d931aef8ed..49b1065fb85 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -23,6 +23,15 @@ HAS_PYUSB = False _PYUSB_ERROR = e +try: + import serial + from serial.tools import list_ports + + HAS_PYSERIAL = True +except ImportError as e: + HAS_PYSERIAL = False + _PYSERIAL_ERROR = e + from pylabrobot.io.capture import CaptureReader, Command, capturer, get_capture_or_validation_active from pylabrobot.io.errors import ValidationError from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences @@ -82,6 +91,7 @@ def __init__( # Will be resolved in setup() self._dev: Optional[Device] = None + self._serial_dev: Optional["serial.Serial"] = None self._executor: Optional[ThreadPoolExecutor] = None if get_capture_or_validation_active(): @@ -95,6 +105,12 @@ def dev(self) -> "Device": raise RuntimeError("Device not initialized. Call setup() first.") return self._dev + @property + def serial_dev(self) -> "serial.Serial": + if self._serial_dev is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + return self._serial_dev + def _resolve_device_serial(self) -> str: """List connected FTDI devices and resolve which one to connect to based on parameters. @@ -178,10 +194,90 @@ def _resolve_device_serial(self) -> str: device_serial_number = cast(str, usb.util.get_string(device, device.iSerialNumber)) return device_serial_number + def _resolve_serial_port(self) -> tuple[str, str]: + if not HAS_PYSERIAL: + global _PYSERIAL_ERROR + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_PYSERIAL_ERROR}" + ) + + candidates = [] + connected_devices_list = [] + for port in list_ports.comports(): + serial_number = port.serial_number or "" + vid = port.vid + pid = port.pid + vid_pid = ( + f"{vid:04x}:{pid:04x}" if vid is not None and pid is not None else "unknown" + ) + connected_devices_list.append(f"{port.device} {serial_number} (VID:PID {vid_pid})") + + if vid is None or pid is None: + continue + if self._vid is not None and vid != self._vid: + continue + if self._vid is None and vid not in pylibftdi.driver.USB_VID_LIST: + continue + if self._pid is not None and pid != self._pid: + continue + if self._pid is None and pid not in pylibftdi.driver.USB_PID_LIST: + continue + + if self._device_id is not None: + if serial_number == self._device_id: + candidates.append((port.device, serial_number, True)) + continue + if serial_number.startswith(self._device_id): + candidates.append((port.device, serial_number, False)) + continue + continue + + candidates.append((port.device, serial_number, True)) + + exact_candidates = [candidate for candidate in candidates if candidate[2]] + if exact_candidates: + candidates = exact_candidates + + connected_devices_string = ", ".join(connected_devices_list) + vid_string = f"{self._vid:04x}" if self._vid is not None else "any" + pid_string = f"{self._pid:04x}" if self._pid is not None else "any" + + if len(candidates) == 0: + raise RuntimeError( + f"No FTDI serial ports found with specified criteria: " + f"VID:PID {vid_string}:{pid_string}, " + f"device_id {self._device_id}. " + "Connected serial ports: " + connected_devices_string + ) + + if len(candidates) > 1: + raise RuntimeError( + f"Multiple FTDI serial ports found with specified criteria: " + f"VID:PID {vid_string}:{pid_string}, " + f"device_id {self._device_id}. " + f"Please specify the device_id parameter explicitly with the serial number of the desired device." + ) + + port_name, serial_number, exact_match = candidates[0] + if not exact_match: + logger.warning( + "Resolved FTDI serial prefix %s to %s on %s", + self._device_id, + serial_number, + port_name, + ) + return port_name, serial_number + async def setup(self): """Initialize the FTDI device connection with device resolution.""" if self._dev is not None and not self._dev.closed: self._dev.close() + if self._serial_dev is not None and self._serial_dev.is_open: + self._serial_dev.close() + self._serial_dev = None + + native_error: Optional[Exception] = None try: # Resolve which device to connect to self._device_id = self._resolve_device_serial() @@ -196,12 +292,31 @@ async def setup(self): ) self._dev.open() logger.info(f"Successfully opened FTDI device: {self.device_id}") - except FtdiError as e: - raise RuntimeError( - f"Failed to open FTDI device for '{self._human_readable_device_name}': {e}. " - "Is the device connected? Is it in use by another process? " - "Try restarting the kernel." - ) from e + except Exception as e: + native_error = e + + if self._dev is None: + try: + port_name, serial_number = self._resolve_serial_port() + self._device_id = serial_number + self._serial_dev = serial.Serial( + port=port_name, + timeout=0, + write_timeout=1, + ) + logger.info( + "Successfully opened FTDI serial port %s for device: %s", + port_name, + self.device_id, + ) + except Exception as serial_error: + raise RuntimeError( + f"Failed to open FTDI device for '{self._human_readable_device_name}'. " + f"pylibftdi error: {native_error}. " + f"pyserial fallback error: {serial_error}. " + "Is the device connected? Is it in use by another process? " + "Try restarting the kernel." + ) from serial_error self._executor = ThreadPoolExecutor(max_workers=1) @@ -213,7 +328,13 @@ def device_id(self) -> str: async def set_baudrate(self, baudrate: int): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: setattr(self.dev, "baudrate", baudrate)) + if self._serial_dev is not None: + await loop.run_in_executor( + self._executor, + lambda: setattr(self.serial_dev, "baudrate", baudrate), + ) + else: + await loop.run_in_executor(self._executor, lambda: setattr(self.dev, "baudrate", baudrate)) logger.log(LOG_LEVEL_IO, "[%s] set_baudrate %s", self._device_id, baudrate) capturer.record( FTDICommand(device_id=self.device_id, action="set_baudrate", data=str(baudrate)) @@ -221,27 +342,35 @@ async def set_baudrate(self, baudrate: int): async def set_rts(self, level: bool): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setrts(level)) + if self._serial_dev is not None: + await loop.run_in_executor(self._executor, lambda: setattr(self.serial_dev, "rts", level)) + else: + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setrts(level)) logger.log(LOG_LEVEL_IO, "[%s] set_rts %s", self._device_id, level) capturer.record(FTDICommand(device_id=self.device_id, action="set_rts", data=str(level))) async def set_dtr(self, level: bool): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setdtr(level)) + if self._serial_dev is not None: + await loop.run_in_executor(self._executor, lambda: setattr(self.serial_dev, "dtr", level)) + else: + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setdtr(level)) logger.log(LOG_LEVEL_IO, "[%s] set_dtr %s", self._device_id, level) capturer.record(FTDICommand(device_id=self.device_id, action="set_dtr", data=str(level))) async def usb_reset(self): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_reset()) + if self._serial_dev is None: + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_reset()) logger.log(LOG_LEVEL_IO, "[%s] usb_reset", self._device_id) capturer.record(FTDICommand(device_id=self.device_id, action="usb_reset", data="")) async def set_latency_timer(self, latency: int): loop = asyncio.get_running_loop() - await loop.run_in_executor( - self._executor, lambda: self.dev.ftdi_fn.ftdi_set_latency_timer(latency) - ) + if self._serial_dev is None: + await loop.run_in_executor( + self._executor, lambda: self.dev.ftdi_fn.ftdi_set_latency_timer(latency) + ) logger.log(LOG_LEVEL_IO, "[%s] set_latency_timer %s", self._device_id, latency) capturer.record( FTDICommand(device_id=self.device_id, action="set_latency_timer", data=str(latency)) @@ -249,9 +378,27 @@ async def set_latency_timer(self, latency: int): async def set_line_property(self, bits: int, stopbits: int, parity: int): loop = asyncio.get_running_loop() - await loop.run_in_executor( - self._executor, lambda: self.dev.ftdi_fn.ftdi_set_line_property(bits, stopbits, parity) - ) + if self._serial_dev is not None: + parity_map = { + 0: serial.PARITY_NONE, + 1: serial.PARITY_ODD, + 2: serial.PARITY_EVEN, + } + stopbits_map = { + 1: serial.STOPBITS_ONE, + 2: serial.STOPBITS_TWO, + } + + def configure_serial_line(): + self.serial_dev.bytesize = bits + self.serial_dev.stopbits = stopbits_map.get(stopbits, stopbits) + self.serial_dev.parity = parity_map.get(parity, serial.PARITY_NONE) + + await loop.run_in_executor(self._executor, configure_serial_line) + else: + await loop.run_in_executor( + self._executor, lambda: self.dev.ftdi_fn.ftdi_set_line_property(bits, stopbits, parity) + ) logger.log( LOG_LEVEL_IO, "[%s] set_line_property %s,%s,%s", self._device_id, bits, stopbits, parity ) @@ -263,7 +410,16 @@ async def set_line_property(self, bits: int, stopbits: int, parity: int): async def set_flowctrl(self, flowctrl: int): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setflowctrl(flowctrl)) + if self._serial_dev is not None: + + def configure_serial_flow_control(): + self.serial_dev.xonxoff = False + self.serial_dev.rtscts = False + self.serial_dev.dsrdtr = False + + await loop.run_in_executor(self._executor, configure_serial_flow_control) + else: + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setflowctrl(flowctrl)) logger.log(LOG_LEVEL_IO, "[%s] set_flowctrl %s", self._device_id, flowctrl) capturer.record( FTDICommand(device_id=self.device_id, action="set_flowctrl", data=str(flowctrl)) @@ -271,27 +427,46 @@ async def set_flowctrl(self, flowctrl: int): async def usb_purge_rx_buffer(self): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_rx_buffer()) + if self._serial_dev is not None: + await loop.run_in_executor(self._executor, self.serial_dev.reset_input_buffer) + else: + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_rx_buffer()) logger.log(LOG_LEVEL_IO, "[%s] usb_purge_rx_buffer", self._device_id) capturer.record(FTDICommand(device_id=self.device_id, action="usb_purge_rx_buffer", data="")) async def usb_purge_tx_buffer(self): loop = asyncio.get_running_loop() - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_tx_buffer()) + if self._serial_dev is not None: + await loop.run_in_executor(self._executor, self.serial_dev.reset_output_buffer) + else: + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_tx_buffer()) logger.log(LOG_LEVEL_IO, "[%s] usb_purge_tx_buffer", self._device_id) capturer.record(FTDICommand(device_id=self.device_id, action="usb_purge_tx_buffer", data="")) async def poll_modem_status(self) -> int: loop = asyncio.get_running_loop() - stat = ctypes.c_ushort(0) - await loop.run_in_executor( - self._executor, lambda: self.dev.ftdi_fn.ftdi_poll_modem_status(ctypes.byref(stat)) - ) - logger.log(LOG_LEVEL_IO, "[%s] poll_modem_status %s", self._device_id, stat.value) + if self._serial_dev is not None: + + def read_serial_modem_status() -> int: + status = 0 + status |= int(bool(self.serial_dev.cts)) << 4 + status |= int(bool(self.serial_dev.dsr)) << 5 + status |= int(bool(self.serial_dev.ri)) << 6 + status |= int(bool(self.serial_dev.cd)) << 7 + return status + + value = await loop.run_in_executor(self._executor, read_serial_modem_status) + else: + stat = ctypes.c_ushort(0) + await loop.run_in_executor( + self._executor, lambda: self.dev.ftdi_fn.ftdi_poll_modem_status(ctypes.byref(stat)) + ) + value = stat.value + logger.log(LOG_LEVEL_IO, "[%s] poll_modem_status %s", self._device_id, value) capturer.record( - FTDICommand(device_id=self.device_id, action="poll_modem_status", data=str(stat.value)) + FTDICommand(device_id=self.device_id, action="poll_modem_status", data=str(value)) ) - return stat.value + return value async def get_serial(self) -> str: return self.device_id @@ -299,6 +474,10 @@ async def get_serial(self) -> str: async def stop(self): if self._dev is not None: self.dev.close() + self._dev = None + if self._serial_dev is not None: + self.serial_dev.close() + self._serial_dev = None if self._executor is not None: self._executor.shutdown(wait=True) self._executor = None @@ -307,10 +486,15 @@ async def write(self, data: bytes) -> int: """Write data to the device. Returns the number of bytes written.""" logger.log(LOG_LEVEL_IO, "[%s] write %s", self._device_id, data) capturer.record(FTDICommand(device_id=self.device_id, action="write", data=data.hex())) + if self._serial_dev is not None: + return cast(int, self.serial_dev.write(data)) return cast(int, self.dev.write(data)) async def read(self, num_bytes: int = 1) -> bytes: - data = self.dev.read(num_bytes) + if self._serial_dev is not None: + data = self.serial_dev.read(num_bytes) + else: + data = self.dev.read(num_bytes) if len(data) != 0: logger.log(LOG_LEVEL_IO, "[%s] read %s", self._device_id, data) capturer.record( @@ -323,7 +507,10 @@ async def read(self, num_bytes: int = 1) -> bytes: return cast(bytes, data) async def readline(self) -> bytes: # type: ignore # very dumb it's reading from pyserial - data = self.dev.readline() + if self._serial_dev is not None: + data = self.serial_dev.readline() + else: + data = self.dev.readline() if len(data) != 0: logger.log(LOG_LEVEL_IO, "[%s] readline %s", self._device_id, data) capturer.record(FTDICommand(device_id=self.device_id, action="readline", data=data.hex())) diff --git a/pyproject.toml b/pyproject.toml index 717e28ccb6d..302a27f0d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = ["typing_extensions", "websockets"] [project.optional-dependencies] serial = ["pyserial"] usb = ["pyusb", "libusb-package"] -ftdi = ["pylibftdi", "pyusb"] +ftdi = ["pylibftdi", "pyusb", "pyserial"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.1"] From 05e7d27206a9b3ad976e0298362d63798334211a Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 11:06:51 +0200 Subject: [PATCH 04/22] Handle blank VSpin runtime status during setup Reject all-zero runtime attach status packets and fall back to full 14-byte status polling when short idle status lacks position/home data during homing. Validated with the local VSpin hardware workflow at 300 g for 10 seconds. --- pylabrobot/centrifuge/v11_vspin_backend.py | 43 ++++++++++++++++++++ pylabrobot/centrifuge/vspin_backend_tests.py | 41 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 9228cf4150c..2096e2390c5 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -387,6 +387,17 @@ async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: self._last_home_position = int(status.home_position) return status + async def _get_full_positions_and_tachometer(self) -> _StatusPositionTachometer: + resp = await self._send_command( + bytes.fromhex("aa01121f32"), + read_timeout=0.40, + expected_len=14, + ) + status = self._parse_position_status(resp) + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + return status + async def get_position(self) -> int: return (await self._get_positions_and_tachometer()).current_position # type: ignore @@ -568,6 +579,10 @@ async def _drain_startup_silence(self, seconds: float) -> None: await self._read_resp(timeout=0.08, expected_len=None, quiet_time=0.01) await asyncio.sleep(0.03) + @staticmethod + def _is_runtime_attach_status(status: _StatusPositionTachometer) -> bool: + return int(status.current_position) != 0 or int(status.home_position) != 0 + async def _try_attach_to_runtime_controller(self) -> bool: """Attach to a VSpin controller that is already in 57600-baud runtime mode.""" await self.io.set_baudrate(57600) @@ -588,6 +603,15 @@ async def _try_attach_to_runtime_controller(self) -> bool: ) status = self._find_status_packet(resp) if status is not None: + if not self._is_runtime_attach_status(status): + logger.debug( + "[vspin] Ignoring blank runtime attach status " + "(status=0x%02x, position=%d, home=%d)", + status.status, + status.current_position, + status.home_position, + ) + continue self._last_position = int(status.current_position) self._last_home_position = int(status.home_position) logger.info( @@ -744,6 +768,25 @@ async def _wait_for_idle( last_status = status is_idle_status = status.status in _IDLE_VSPIN_STATUSES is_stopped = abs(status.tachometer) <= 2 + should_probe_full_status = is_idle_status and is_stopped and ( + (require_activity_from is not None and not observed_activity) + or ( + target_position is not None + and not _vspin_position_matches_target( + position=int(status.current_position), + target=int(target_position), + tolerance=tolerance, + ) + ) + ) + if should_probe_full_status: + full_status = await self._get_full_positions_and_tachometer() + if full_status.status in _IDLE_VSPIN_STATUSES and abs(full_status.tachometer) <= 2: + status = full_status + last_status = status + is_idle_status = True + is_stopped = True + if require_activity_from is not None and not observed_activity: observed_activity = ( not is_idle_status diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 8b9a59c10bc..fde87777977 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -406,6 +406,47 @@ async def send_command(cmd: bytes, read_timeout: float, expected_len: int): self.assertEqual(backend._last_position, 12070) self.assertEqual(backend._last_home_position, 6733) + async def test_runtime_attach_rejects_blank_zero_status(self): + backend = _make_backend(_FakeIO([])) + backend.io.set_baudrate = mock.AsyncMock() + backend.io.set_rts = mock.AsyncMock() + backend.io.set_dtr = mock.AsyncMock() + backend._purge_io_buffers = mock.AsyncMock() + backend._drain_startup_silence = mock.AsyncMock() + + async def send_command(cmd: bytes, read_timeout: float, expected_len: int): + return _status_packet(status=0x09, current_position=0, tachometer=0, home_position=0) + + backend._send_command = send_command + + self.assertFalse(await backend._try_attach_to_runtime_controller()) + self.assertEqual(backend._last_position, 0) + self.assertEqual(backend._last_home_position, 0) + + async def test_wait_for_idle_uses_full_status_when_short_idle_has_no_position(self): + full_status = _status_packet( + status=0x09, + current_position=8006, + tachometer=0, + home_position=7924, + ) + backend = _make_backend(_FakeIO([bytes.fromhex("0909"), full_status])) + reference = backend._make_status(0x09, 0, home_position=0) + + status = await backend._wait_for_idle( + label="homing", + timeout=1.0, + require_activity_from=reference, + activity_tolerance=POSITION_SETTLE_TOLERANCE, + ) + + self.assertEqual(status.current_position, 8006) + self.assertEqual(status.home_position, 7924) + self.assertEqual( + backend.io.writes, + [bytes.fromhex("aa010e0f"), bytes.fromhex("aa01121f32")], + ) + async def test_wait_for_idle_can_reject_stale_idle_before_homing_activity(self): stale_status = _status_packet( status=0x09, From 73ce745ce36e4f3db4e5c880206d6f999c6f6930 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 11:36:30 +0200 Subject: [PATCH 05/22] Require valid VSpin homing and spin speed Do not accept a zero home position after homing, refresh bucket targets when the home reference is unknown, and require measured RPM before considering a spin cycle started. Validated with the local VSpin hardware workflow at 300 g for 10 seconds. --- pylabrobot/centrifuge/v11_vspin_backend.py | 30 +++++++++++---- pylabrobot/centrifuge/vspin_backend_tests.py | 39 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 2096e2390c5..d6167ccc89a 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -257,11 +257,15 @@ async def _get_bucket_position(self, bucket_num: int) -> int: raise bucket_1_not_set_error home_position = self._home_sensor_position + if home_position == 0: + home_position = None if home_position is None: live_home_position = await self.get_home_position() if live_home_position == 0: await self._home_rotor() home_position = self._home_sensor_position + if home_position == 0: + home_position = None else: home_position = live_home_position @@ -705,7 +709,7 @@ async def _home_rotor(self) -> None: expected_len=14, expect_response=False, ) - status = await self._wait_for_full_status(timeout=5.0, allow_zero_home_fallback=True) + status = await self._wait_for_full_status(timeout=15.0) self._last_position = int(status.current_position) self._last_home_position = int(status.home_position) @@ -860,17 +864,29 @@ async def _wait_for_door(self, open_expected: bool, timeout: float) -> None: expected = "open" if open_expected else "closed" raise TimeoutError(f"VSpin door did not report {expected} within {timeout:.1f}s") - async def _wait_for_speed_or_motion(self, rpm: int, final_position: int) -> None: - deadline = time.monotonic() + 25.0 + async def _wait_for_speed_or_motion( + self, + rpm: int, + final_position: int, + timeout: float = 25.0, + ) -> None: + deadline = time.monotonic() + timeout + last_status = self._make_status(0x19, self._last_position, home_position=self._last_home_position) + last_live_rpm = 0.0 while time.monotonic() < deadline and not self._stop_requested: status = await self._get_positions_and_tachometer() - live_rpm = status.tachometer * TACH_TO_RPM - if live_rpm >= rpm * 0.92: - return - if status.current_position >= final_position: + last_status = status + last_live_rpm = abs(status.tachometer * TACH_TO_RPM) + if last_live_rpm >= rpm * 0.92: return await asyncio.sleep(0.25) + raise TimeoutError( + f"VSpin did not reach target speed {rpm} rpm within {timeout:.1f}s " + f"(last rpm={last_live_rpm:.1f}, position={last_status.current_position}, " + f"home={last_status.home_position})" + ) + async def _hold_spin(self, duration: float) -> None: started = time.monotonic() while not self._stop_requested and time.monotonic() - started < duration: diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index fde87777977..e4e2b1f0141 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -467,6 +467,45 @@ async def test_wait_for_idle_can_reject_stale_idle_before_homing_activity(self): activity_tolerance=POSITION_SETTLE_TOLERANCE, ) + async def test_home_rotor_requires_nonzero_home_position_after_idle(self): + backend = _make_backend(_FakeIO([])) + backend._get_positions_and_tachometer = mock.AsyncMock( + return_value=backend._make_status(0x09, 1000, home_position=0), + ) + backend._motor_enable = mock.AsyncMock() + backend._send_safe = mock.AsyncMock() + backend._wait_for_idle = mock.AsyncMock( + return_value=backend._make_status(0x09, 1000, home_position=0), + ) + backend._wait_for_full_status = mock.AsyncMock( + return_value=backend._make_status(0x09, 1000, home_position=7859), + ) + + await backend._home_rotor() + + backend._wait_for_full_status.assert_awaited_once_with(timeout=15.0) + self.assertEqual(backend._home_sensor_position, 7859) + + async def test_speed_wait_requires_measured_rpm_not_position_only(self): + backend = _make_backend( + _FakeIO([ + _status_packet(status=0x09, current_position=5000, tachometer=0, home_position=100), + ]) + ) + + with self.assertRaisesRegex(TimeoutError, "target speed"): + await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.01) + + async def test_speed_wait_accepts_measured_target_rpm(self): + tachometer = int(-(1000 * 0.95) / 14.69320388) + backend = _make_backend( + _FakeIO([ + _status_packet(status=0x08, current_position=1000, tachometer=tachometer, home_position=100), + ]) + ) + + await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.5) + async def test_configuration_reasserts_control_lines_before_startup_baud(self): io = _RecordingConfigIO() backend = _make_backend(io) # type: ignore[arg-type] From b145da928431b8b780f60b99afff928d5c0093a6 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 12:28:19 +0200 Subject: [PATCH 06/22] Preserve VSpin spin profile command sequence --- pylabrobot/centrifuge/v11_vspin_backend.py | 3 +- pylabrobot/centrifuge/vspin_backend_tests.py | 40 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index d6167ccc89a..440280118f1 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -1111,13 +1111,14 @@ async def spin_rpm( try: await self._prepare_spin_motion() await self._motor_enable() + # Sample before entering the spin profile; D4 97 must follow it immediately. + current_position = await self.get_position() await self._send_safe( bytes.fromhex("aa01e60500640000000000fd00803e01000c"), timeout=0.25, expected_len=14, ) - current_position = await self.get_position() spin_command, final_position = _build_vspin_spin_command( current_position=current_position, rpm=rpm, diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index e4e2b1f0141..3876740e273 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -506,6 +506,46 @@ async def test_speed_wait_accepts_measured_target_rpm(self): await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.5) + async def test_spin_rpm_sends_spin_command_immediately_after_spin_profile(self): + backend = _make_backend(_FakeIO([])) + events = [] + + async def get_door_open() -> bool: + return False + + async def get_door_locked() -> bool: + return True + + async def get_bucket_locked() -> bool: + return False + + async def get_position() -> int: + events.append("get_position") + return 12074 + + async def send_safe(cmd: bytes, **kwargs): + del kwargs + events.append(cmd.hex()) + return _status_packet(status=0x09, current_position=12074, tachometer=0) + + backend.get_door_open = get_door_open + backend.get_door_locked = get_door_locked + backend.get_bucket_locked = get_bucket_locked + backend.get_position = get_position + backend._prepare_spin_motion = mock.AsyncMock() + backend._motor_enable = mock.AsyncMock() + backend._send_safe = send_safe + backend._wait_for_speed_or_motion = mock.AsyncMock() + backend._hold_spin = mock.AsyncMock() + backend._wait_for_idle = mock.AsyncMock() + backend._home_rotor = mock.AsyncMock() + + await backend.spin_rpm(rpm=1500, duration=10) + + profile_index = events.index("aa01e60500640000000000fd00803e01000c") + self.assertEqual(events[profile_index + 1][:8], "aa01d497") + self.assertLess(events.index("get_position"), profile_index) + async def test_configuration_reasserts_control_lines_before_startup_baud(self): io = _RecordingConfigIO() backend = _make_backend(io) # type: ignore[arg-type] From 3b3c82f7490d0ef17f064077d343ce1b679a8171 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 12:43:44 +0200 Subject: [PATCH 07/22] Stabilize VSpin pneumatic settling --- pylabrobot/centrifuge/v11_vspin_backend.py | 101 +++++++++++++++++-- pylabrobot/centrifuge/vspin_backend_tests.py | 50 +++++++++ 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 440280118f1..25fb2355cc9 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -58,6 +58,8 @@ def _save_vspin_calibrations(device_id, remainder: int): POSITION_SETTLE_TOLERANCE: int = 200 TACH_TO_RPM: float = -14.69320388 DOOR_OPEN_SETTLE_SECONDS: float = 2.0 +DOOR_CLOSE_SETTLE_SECONDS: float = 1.0 +PNEUMATIC_SETTLE_SECONDS: float = 0.35 _KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x0B, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} _IDLE_VSPIN_STATUSES = {0x09, 0x0B, 0x11, 0x89, 0x91} @@ -832,18 +834,82 @@ async def _wait_for_idle( async def _prepare_bucket_motion(self) -> None: await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.20) - await self._poll_io_status(0.25) + await self._wait_for_io_state( + label="bucket motion lock", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._poll_io_status(0.25) + await self._wait_for_io_state( + label="bucket motion ready", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=False, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) self._motion_is_prepared = True async def _prepare_spin_motion(self) -> None: await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.20) - await self._poll_io_status(0.40) + await self._wait_for_io_state( + label="spin bucket lock", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._poll_io_status(0.30) + await self._wait_for_io_state( + label="spin ready", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=False, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) self._motion_is_prepared = True + async def _wait_for_io_state( + self, + label: str, + timeout: float, + door_open: Optional[bool] = None, + door_locked: Optional[bool] = None, + bucket_locked: Optional[bool] = None, + ) -> None: + end = time.monotonic() + timeout + last_status = b"" + while time.monotonic() < end: + try: + last_status = await self._get_status() + except IOError: + await asyncio.sleep(0.05) + continue + + if len(last_status) >= 3: + value = last_status[2] + matches = [] + if door_open is not None: + matches.append((value & 0b0010 != 0) is door_open) + if door_locked is not None: + matches.append((value & 0b0100 == 0) is door_locked) + if bucket_locked is not None: + matches.append((value & 0b0001 != 0) is bucket_locked) + if all(matches): + return + + await asyncio.sleep(0.05) + + raise TimeoutError( + f"VSpin {label} IO state was not reached within {timeout:.1f}s " + f"(last status={last_status.hex() or '(empty)'})" + ) + async def _send_deceleration(self, deceleration: float) -> None: await self._send_safe( bytes.fromhex("aa01e60500640000000000fd00803e01000c"), @@ -967,6 +1033,7 @@ async def close_door(self): await self._send_safe(bytes.fromhex("aa022600052d"), timeout=0.30) await self._wait_for_door(open_expected=False, timeout=4.0) + await asyncio.sleep(DOOR_CLOSE_SETTLE_SECONDS) self._motion_is_prepared = False async def lock_door(self): @@ -975,6 +1042,14 @@ async def lock_door(self): if await self.get_door_locked(): return await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) + await self._wait_for_io_state( + label="door lock", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=False, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) async def unlock_door(self): if not await self.get_door_locked(): @@ -985,14 +1060,24 @@ async def lock_bucket(self): if await self.get_bucket_locked(): return await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.25) - await self._poll_io_status(0.35) + await self._wait_for_io_state( + label="bucket lock", + timeout=1.5, + bucket_locked=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) self._motion_is_prepared = False async def unlock_bucket(self): if not await self.get_bucket_locked(): return await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.25) - await self._poll_io_status(0.25) + await self._wait_for_io_state( + label="bucket unlock", + timeout=1.5, + bucket_locked=False, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) self._motion_is_prepared = True async def go_to_bucket1(self): @@ -1101,10 +1186,6 @@ async def spin_rpm( if await self.get_door_open(): await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if await self.get_bucket_locked(): - await self.unlock_bucket() self._stop_requested = False diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 3876740e273..8141e7b6e28 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -506,6 +506,54 @@ async def test_speed_wait_accepts_measured_target_rpm(self): await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.5) + async def test_prepare_spin_motion_waits_for_spin_ready_io_state(self): + backend = _make_backend(_FakeIO([])) + commands = [] + statuses = [ + bytes.fromhex("0088090091"), + bytes.fromhex("0008080010"), + ] + + async def send_safe(cmd: bytes, **kwargs): + del kwargs + commands.append(cmd) + return b"" + + async def get_status() -> bytes: + commands.append(bytes.fromhex("aa020e10")) + return statuses.pop(0) + + backend._send_safe = send_safe + backend._get_status = get_status + + await backend._prepare_spin_motion() + + self.assertEqual( + commands, + [ + bytes.fromhex("aa0226000129"), + bytes.fromhex("aa020e10"), + bytes.fromhex("aa0226000028"), + bytes.fromhex("aa020e10"), + ], + ) + + async def test_prepare_spin_motion_rejects_locked_bucket_before_spin(self): + backend = _make_backend(_FakeIO([])) + + async def send_safe(cmd: bytes, **kwargs): + del cmd, kwargs + return b"" + + async def get_status() -> bytes: + return bytes.fromhex("0088090091") + + backend._send_safe = send_safe + backend._get_status = get_status + + with self.assertRaisesRegex(TimeoutError, "spin ready"): + await backend._prepare_spin_motion() + async def test_spin_rpm_sends_spin_command_immediately_after_spin_profile(self): backend = _make_backend(_FakeIO([])) events = [] @@ -539,6 +587,8 @@ async def send_safe(cmd: bytes, **kwargs): backend._hold_spin = mock.AsyncMock() backend._wait_for_idle = mock.AsyncMock() backend._home_rotor = mock.AsyncMock() + backend.lock_door = mock.AsyncMock(side_effect=AssertionError("unexpected lock door")) + backend.unlock_bucket = mock.AsyncMock(side_effect=AssertionError("unexpected unlock bucket")) await backend.spin_rpm(rpm=1500, duration=10) From bcf90debd3685c155a6f7125f58af31de71329fd Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 12:50:36 +0200 Subject: [PATCH 08/22] Wait for settled VSpin IO states --- pylabrobot/centrifuge/v11_vspin_backend.py | 12 +++++++++--- pylabrobot/centrifuge/vspin_backend_tests.py | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 25fb2355cc9..1c1c605b0aa 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -845,10 +845,11 @@ async def _prepare_bucket_motion(self) -> None: await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) await self._wait_for_io_state( label="bucket motion ready", - timeout=1.5, + timeout=2.5, door_open=False, door_locked=True, bucket_locked=False, + settled=True, ) await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) self._motion_is_prepared = True @@ -866,10 +867,11 @@ async def _prepare_spin_motion(self) -> None: await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) await self._wait_for_io_state( label="spin ready", - timeout=1.5, + timeout=2.5, door_open=False, door_locked=True, bucket_locked=False, + settled=True, ) await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) self._motion_is_prepared = True @@ -881,6 +883,7 @@ async def _wait_for_io_state( door_open: Optional[bool] = None, door_locked: Optional[bool] = None, bucket_locked: Optional[bool] = None, + settled: bool = False, ) -> None: end = time.monotonic() + timeout last_status = b"" @@ -900,6 +903,8 @@ async def _wait_for_io_state( matches.append((value & 0b0100 == 0) is door_locked) if bucket_locked is not None: matches.append((value & 0b0001 != 0) is bucket_locked) + if settled: + matches.append(last_status[1] in (0x00, 0x10)) if all(matches): return @@ -1044,10 +1049,11 @@ async def lock_door(self): await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) await self._wait_for_io_state( label="door lock", - timeout=1.5, + timeout=2.5, door_open=False, door_locked=True, bucket_locked=False, + settled=True, ) await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 8141e7b6e28..813effe9dd8 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -512,6 +512,7 @@ async def test_prepare_spin_motion_waits_for_spin_ready_io_state(self): statuses = [ bytes.fromhex("0088090091"), bytes.fromhex("0008080010"), + bytes.fromhex("0010080018"), ] async def send_safe(cmd: bytes, **kwargs): @@ -535,6 +536,7 @@ async def get_status() -> bytes: bytes.fromhex("aa020e10"), bytes.fromhex("aa0226000028"), bytes.fromhex("aa020e10"), + bytes.fromhex("aa020e10"), ], ) From 27c574741f85a7385643d2de14e0e0ece0da2cbe Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 12:56:04 +0200 Subject: [PATCH 09/22] Add VSpin door unlock settle before opening --- pylabrobot/centrifuge/v11_vspin_backend.py | 8 ++++ pylabrobot/centrifuge/vspin_backend_tests.py | 44 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 1c1c605b0aa..fa8e7ee43c7 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -58,6 +58,7 @@ def _save_vspin_calibrations(device_id, remainder: int): POSITION_SETTLE_TOLERANCE: int = 200 TACH_TO_RPM: float = -14.69320388 DOOR_OPEN_SETTLE_SECONDS: float = 2.0 +DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 DOOR_CLOSE_SETTLE_SECONDS: float = 1.0 PNEUMATIC_SETTLE_SECONDS: float = 0.35 @@ -1025,6 +1026,13 @@ async def open_door(self): except IOError: pass + try: + if await self.get_door_locked(): + await self._send_safe(bytes.fromhex("aa022600042c"), timeout=0.20) + await asyncio.sleep(DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS) + except IOError: + pass + await self._send_safe(bytes.fromhex("aa022600072f"), timeout=0.30) await self._wait_for_door(open_expected=True, timeout=4.0) await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 813effe9dd8..05ad62b48b1 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -506,6 +506,50 @@ async def test_speed_wait_accepts_measured_target_rpm(self): await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.5) + async def test_open_door_waits_after_unlock_before_open_command(self): + backend = _make_backend(_FakeIO([])) + events = [] + + async def get_door_open() -> bool: + events.append("get_door_open") + return False + + async def get_door_locked() -> bool: + events.append("get_door_locked") + return True + + async def send_safe(cmd: bytes, **kwargs): + del kwargs + events.append(cmd.hex()) + return b"" + + async def wait_for_door(open_expected: bool, timeout: float) -> None: + events.append(("wait_for_door", open_expected, timeout)) + + async def sleep(seconds: float) -> None: + events.append(("sleep", seconds)) + + backend.get_door_open = get_door_open + backend.get_door_locked = get_door_locked + backend._send_safe = send_safe + backend._wait_for_door = wait_for_door + + with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.asyncio.sleep", sleep): + await backend.open_door() + + self.assertEqual( + events, + [ + "get_door_open", + "get_door_locked", + "aa022600042c", + ("sleep", 0.5), + "aa022600072f", + ("wait_for_door", True, 4.0), + ("sleep", 2.0), + ], + ) + async def test_prepare_spin_motion_waits_for_spin_ready_io_state(self): backend = _make_backend(_FakeIO([])) commands = [] From 023c286808dd5b76d725db96f4b586a0c3cf52a1 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 13:09:32 +0200 Subject: [PATCH 10/22] Retry VSpin position moves after wrong idle --- pylabrobot/centrifuge/v11_vspin_backend.py | 50 +++++++++----- pylabrobot/centrifuge/vspin_backend_tests.py | 69 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index fa8e7ee43c7..8e2f2318a56 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -56,6 +56,7 @@ def _save_vspin_calibrations(device_id, remainder: int): STATUS_POLL_INTERVAL: float = 0.15 POSITION_TOLERANCE: int = 15 POSITION_SETTLE_TOLERANCE: int = 200 +POSITION_MOVE_ATTEMPTS: int = 2 TACH_TO_RPM: float = -14.69320388 DOOR_OPEN_SETTLE_SECONDS: float = 2.0 DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 @@ -1109,23 +1110,38 @@ async def go_to_position(self, position: int): if not self._motion_is_prepared: await self._prepare_bucket_motion() - await self._motor_enable() - await self._send_safe( - bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), - timeout=0.20, - expected_len=14, - ) - await self._send_safe( - _build_vspin_position_command(position), - timeout=0.25, - expected_len=14, - ) - await self._wait_for_idle( - label=f"position {position}", - timeout=25.0, - target_position=position, - tolerance=POSITION_SETTLE_TOLERANCE, - ) + for attempt in range(1, POSITION_MOVE_ATTEMPTS + 1): + if attempt > 1: + logger.warning( + "[vspin] Retrying position move to %d after wrong idle position", + position, + ) + self._motion_is_prepared = False + await self._prepare_bucket_motion() + + await self._motor_enable() + await self._send_safe( + bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), + timeout=0.20, + expected_len=14, + ) + await self._send_safe( + _build_vspin_position_command(position), + timeout=0.25, + expected_len=14, + ) + try: + await self._wait_for_idle( + label=f"position {position}", + timeout=25.0, + target_position=position, + tolerance=POSITION_SETTLE_TOLERANCE, + ) + break + except TimeoutError: + if attempt == POSITION_MOVE_ATTEMPTS: + raise + await self.lock_bucket() await self.open_door() diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 05ad62b48b1..e4243f01cc7 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -550,6 +550,75 @@ async def sleep(seconds: float) -> None: ], ) + async def test_go_to_position_retries_after_wrong_idle_position(self): + backend = _make_backend(_FakeIO([])) + events = [] + wait_calls = 0 + + async def get_door_open() -> bool: + return False + + async def get_door_locked() -> bool: + return True + + async def prepare_bucket_motion() -> None: + events.append("prepare") + + async def motor_enable() -> None: + events.append("motor") + + async def send_safe(cmd: bytes, **kwargs): + del kwargs + if cmd.startswith(bytes.fromhex("aa01e6c8")): + events.append("profile") + elif cmd.startswith(bytes.fromhex("aa01d497")): + events.append("move") + return b"" + + async def wait_for_idle(**kwargs) -> None: + nonlocal wait_calls + del kwargs + events.append("wait") + wait_calls += 1 + if wait_calls == 1: + raise TimeoutError("wrong idle position") + + async def lock_bucket() -> None: + events.append("lock_bucket") + + async def open_door() -> None: + events.append("open_door") + + backend.get_door_open = get_door_open + backend.get_door_locked = get_door_locked + backend._prepare_bucket_motion = prepare_bucket_motion + backend._motor_enable = motor_enable + backend._send_safe = send_safe + backend._wait_for_idle = wait_for_idle + backend.lock_bucket = lock_bucket + backend.open_door = open_door + + with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.logger.warning"): + await backend.go_to_position(11842) + + self.assertEqual( + events, + [ + "prepare", + "motor", + "profile", + "move", + "wait", + "prepare", + "motor", + "profile", + "move", + "wait", + "lock_bucket", + "open_door", + ], + ) + async def test_prepare_spin_motion_waits_for_spin_ready_io_state(self): backend = _make_backend(_FakeIO([])) commands = [] From 73d734a457ccc5e6e0f85d86d6c1e510bb90123d Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 13:16:24 +0200 Subject: [PATCH 11/22] Retry VSpin spin start when speed is not reached --- pylabrobot/centrifuge/v11_vspin_backend.py | 49 ++++++----- pylabrobot/centrifuge/vspin_backend_tests.py | 88 ++++++++++++++++++++ 2 files changed, 118 insertions(+), 19 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 8e2f2318a56..47cc53d47f0 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -57,6 +57,7 @@ def _save_vspin_calibrations(device_id, remainder: int): POSITION_TOLERANCE: int = 15 POSITION_SETTLE_TOLERANCE: int = 200 POSITION_MOVE_ATTEMPTS: int = 2 +SPIN_START_ATTEMPTS: int = 2 TACH_TO_RPM: float = -14.69320388 DOOR_OPEN_SETTLE_SECONDS: float = 2.0 DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 @@ -1220,27 +1221,37 @@ async def spin_rpm( self._stop_requested = False try: - await self._prepare_spin_motion() - await self._motor_enable() - # Sample before entering the spin profile; D4 97 must follow it immediately. - current_position = await self.get_position() - await self._send_safe( - bytes.fromhex("aa01e60500640000000000fd00803e01000c"), - timeout=0.25, - expected_len=14, - ) + for attempt in range(1, SPIN_START_ATTEMPTS + 1): + try: + await self._prepare_spin_motion() + await self._motor_enable() + # Sample before entering the spin profile; D4 97 must follow it immediately. + current_position = await self.get_position() + await self._send_safe( + bytes.fromhex("aa01e60500640000000000fd00803e01000c"), + timeout=0.25, + expected_len=14, + ) - spin_command, final_position = _build_vspin_spin_command( - current_position=current_position, - rpm=rpm, - duration=duration, - acceleration=acceleration, - ) - await self._send_safe(spin_command, timeout=0.25, expected_len=14) + spin_command, final_position = _build_vspin_spin_command( + current_position=current_position, + rpm=rpm, + duration=duration, + acceleration=acceleration, + ) + await self._send_safe(spin_command, timeout=0.25, expected_len=14) - await self._wait_for_speed_or_motion(rpm=rpm, final_position=final_position) - await self._hold_spin(duration) - await self._send_deceleration(deceleration) + await self._wait_for_speed_or_motion(rpm=rpm, final_position=final_position) + await self._hold_spin(duration) + await self._send_deceleration(deceleration) + break + except TimeoutError: + if attempt == SPIN_START_ATTEMPTS: + raise + logger.warning("[vspin] Retrying spin start after target speed was not reached") + await self._send_deceleration(deceleration) + await self._wait_for_idle(label="spin retry rundown", timeout=45.0) + self._motion_is_prepared = False except asyncio.CancelledError: await self._send_deceleration(deceleration) raise diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index e4243f01cc7..a06a6aae3a5 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -711,6 +711,94 @@ async def send_safe(cmd: bytes, **kwargs): self.assertEqual(events[profile_index + 1][:8], "aa01d497") self.assertLess(events.index("get_position"), profile_index) + async def test_spin_rpm_retries_when_target_speed_is_not_reached(self): + backend = _make_backend(_FakeIO([])) + events = [] + speed_waits = 0 + + async def get_door_open() -> bool: + return False + + async def get_door_locked() -> bool: + return True + + async def get_bucket_locked() -> bool: + return False + + async def get_position() -> int: + events.append("get_position") + return 12074 + + async def send_safe(cmd: bytes, **kwargs): + del kwargs + if cmd.startswith(bytes.fromhex("aa01e605")): + events.append("profile") + elif cmd.startswith(bytes.fromhex("aa01d497")): + events.append("spin") + return _status_packet(status=0x09, current_position=12074, tachometer=0) + + async def prepare_spin_motion() -> None: + events.append("prepare_spin") + + async def motor_enable() -> None: + events.append("motor") + + async def wait_for_speed_or_motion(**kwargs) -> None: + nonlocal speed_waits + del kwargs + events.append("wait_speed") + speed_waits += 1 + if speed_waits == 1: + raise TimeoutError("speed") + + async def hold_spin(duration: float) -> None: + events.append(("hold", duration)) + + async def send_deceleration(deceleration: float) -> None: + events.append(("decelerate", deceleration)) + + async def wait_for_idle(**kwargs) -> None: + events.append(("wait_idle", kwargs["label"])) + + backend.get_door_open = get_door_open + backend.get_door_locked = get_door_locked + backend.get_bucket_locked = get_bucket_locked + backend.get_position = get_position + backend._send_safe = send_safe + backend._prepare_spin_motion = prepare_spin_motion + backend._motor_enable = motor_enable + backend._wait_for_speed_or_motion = wait_for_speed_or_motion + backend._hold_spin = hold_spin + backend._send_deceleration = send_deceleration + backend._wait_for_idle = wait_for_idle + backend._home_rotor = mock.AsyncMock() + + with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.logger.warning"): + await backend.spin_rpm(rpm=1500, duration=10) + + self.assertEqual( + events, + [ + "prepare_spin", + "motor", + "get_position", + "profile", + "spin", + "wait_speed", + ("decelerate", 0.8), + ("wait_idle", "spin retry rundown"), + "prepare_spin", + "motor", + "get_position", + "profile", + "spin", + "wait_speed", + ("hold", 10.0), + ("decelerate", 0.8), + ("wait_idle", "spin rundown"), + ], + ) + async def test_configuration_reasserts_control_lines_before_startup_baud(self): io = _RecordingConfigIO() backend = _make_backend(io) # type: ignore[arg-type] From 35d3995daf2416dc6109de3c2085ea0b3ff79d51 Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 14:31:26 +0200 Subject: [PATCH 12/22] Wait for fresh VSpin homing reference --- pylabrobot/centrifuge/v11_vspin_backend.py | 77 +++++++++++++++++--- pylabrobot/centrifuge/vspin_backend_tests.py | 36 ++++++++- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index 47cc53d47f0..cebb1b77e9c 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -58,6 +58,8 @@ def _save_vspin_calibrations(device_id, remainder: int): POSITION_SETTLE_TOLERANCE: int = 200 POSITION_MOVE_ATTEMPTS: int = 2 SPIN_START_ATTEMPTS: int = 2 +HOMING_TIMEOUT: float = 60.0 +HOMING_MIN_WAIT_SECONDS: float = 1.0 TACH_TO_RPM: float = -14.69320388 DOOR_OPEN_SETTLE_SECONDS: float = 2.0 DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 @@ -686,6 +688,7 @@ async def _motor_enable(self) -> None: async def _home_rotor(self) -> None: pre_home_status = await self._get_positions_and_tachometer() + homing_started_at = time.monotonic() await self._motor_enable() await self._send_safe(bytes.fromhex("aa010001"), timeout=0.30, expected_len=14) await self._send_safe( @@ -702,24 +705,80 @@ async def _home_rotor(self) -> None: status = await self._wait_for_idle( label="homing", - timeout=35.0, - min_wait=1.0, + timeout=HOMING_TIMEOUT, + min_wait=HOMING_MIN_WAIT_SECONDS, require_activity_from=pre_home_status, activity_tolerance=POSITION_SETTLE_TOLERANCE, ) - if status.home_position == 0: - await self._send_safe( - bytes.fromhex("aa01121f32"), - timeout=0.35, - expected_len=14, - expect_response=False, + + if not self._has_fresh_home_reference( + status=status, + previous_home_position=int(pre_home_status.home_position), + ): + remaining_timeout = max(0.0, HOMING_TIMEOUT - (time.monotonic() - homing_started_at)) + logger.debug( + "[vspin] Waiting for fresh homing reference " + "(previous home=%d, current home=%d)", + pre_home_status.home_position, + status.home_position, + ) + status = await self._wait_for_homed_status( + pre_home_status=pre_home_status, + timeout=remaining_timeout, ) - status = await self._wait_for_full_status(timeout=15.0) self._last_position = int(status.current_position) self._last_home_position = int(status.home_position) self._home_sensor_position = _normalize_vspin_home_position(status.home_position) + @staticmethod + def _has_fresh_home_reference( + status: _StatusPositionTachometer, + previous_home_position: int, + ) -> bool: + if int(status.home_position) == 0: + return False + if int(previous_home_position) == 0: + return True + return int(status.home_position) != int(previous_home_position) + + async def _wait_for_homed_status( + self, + pre_home_status: _StatusPositionTachometer, + timeout: float, + ) -> _StatusPositionTachometer: + end = time.monotonic() + timeout + previous_home_position = int(pre_home_status.home_position) + last_status: Optional[V11VSpinBackend._StatusPositionTachometer] = None + + while time.monotonic() < end: + status = await self._get_full_positions_and_tachometer() + last_status = status + is_idle_status = status.status in _IDLE_VSPIN_STATUSES + is_stopped = abs(status.tachometer) <= 2 + if ( + is_idle_status + and is_stopped + and self._has_fresh_home_reference(status, previous_home_position) + ): + return status + await asyncio.sleep(STATUS_POLL_INTERVAL) + + if last_status is None: + last_status = self._make_status( + 0x19, + self._last_position, + home_position=self._last_home_position, + ) + raise TimeoutError( + "VSpin homing did not report a fresh home position within " + f"{timeout:.1f}s (previous home={previous_home_position}, " + f"last status=0x{last_status.status:02x}, " + f"position={last_status.current_position}, " + f"tachometer={last_status.tachometer}, " + f"home={last_status.home_position})" + ) + async def _wait_for_full_status( self, timeout: float, diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index a06a6aae3a5..75716f1aa03 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -3,6 +3,7 @@ from pylabrobot.centrifuge.v11_vspin_backend import ( DEFAULT_BUCKET_1_REMAINDER, + HOMING_TIMEOUT, POSITION_SETTLE_TOLERANCE, POSITION_TOLERANCE, V11VSpinBackend, @@ -477,15 +478,46 @@ async def test_home_rotor_requires_nonzero_home_position_after_idle(self): backend._wait_for_idle = mock.AsyncMock( return_value=backend._make_status(0x09, 1000, home_position=0), ) - backend._wait_for_full_status = mock.AsyncMock( + backend._wait_for_homed_status = mock.AsyncMock( return_value=backend._make_status(0x09, 1000, home_position=7859), ) await backend._home_rotor() - backend._wait_for_full_status.assert_awaited_once_with(timeout=15.0) + backend._wait_for_homed_status.assert_awaited_once() self.assertEqual(backend._home_sensor_position, 7859) + async def test_home_rotor_waits_for_fresh_home_reference_after_runtime_attach(self): + backend = _make_backend(_FakeIO([])) + pre_home_status = backend._make_status(0x09, 4175, tachometer=0, home_position=4076) + stale_home_status = backend._make_status(0x89, 1145, tachometer=0, home_position=4076) + fresh_home_status = backend._make_status(0x89, 1202, tachometer=0, home_position=7900) + + backend._get_positions_and_tachometer = mock.AsyncMock(return_value=pre_home_status) + backend._motor_enable = mock.AsyncMock() + backend._send_safe = mock.AsyncMock() + backend._wait_for_idle = mock.AsyncMock(return_value=stale_home_status) + backend._wait_for_homed_status = mock.AsyncMock(return_value=fresh_home_status) + + await backend._home_rotor() + + backend._wait_for_idle.assert_awaited_once_with( + label="homing", + timeout=HOMING_TIMEOUT, + min_wait=1.0, + require_activity_from=pre_home_status, + activity_tolerance=POSITION_SETTLE_TOLERANCE, + ) + backend._wait_for_homed_status.assert_awaited_once() + self.assertEqual( + backend._wait_for_homed_status.await_args.kwargs["pre_home_status"], + pre_home_status, + ) + self.assertGreater(backend._wait_for_homed_status.await_args.kwargs["timeout"], 0) + self.assertEqual(backend._last_position, 1202) + self.assertEqual(backend._last_home_position, 7900) + self.assertEqual(backend._home_sensor_position, 7900) + async def test_speed_wait_requires_measured_rpm_not_position_only(self): backend = _make_backend( _FakeIO([ From 2574e9852a0943e3410a9deeae0fb20b9becef2f Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 14:40:31 +0200 Subject: [PATCH 13/22] Settle after direct VSpin door unlock --- pylabrobot/centrifuge/v11_vspin_backend.py | 5 ++-- pylabrobot/centrifuge/vspin_backend_tests.py | 31 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py index cebb1b77e9c..c8c0c0ad871 100644 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ b/pylabrobot/centrifuge/v11_vspin_backend.py @@ -1088,9 +1088,7 @@ async def open_door(self): pass try: - if await self.get_door_locked(): - await self._send_safe(bytes.fromhex("aa022600042c"), timeout=0.20) - await asyncio.sleep(DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS) + await self.unlock_door() except IOError: pass @@ -1130,6 +1128,7 @@ async def unlock_door(self): if not await self.get_door_locked(): return await self._send_safe(bytes.fromhex("aa022600042c"), timeout=0.20) + await asyncio.sleep(DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS) async def lock_bucket(self): if await self.get_bucket_locked(): diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 75716f1aa03..7f6f4de606d 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -582,6 +582,37 @@ async def sleep(seconds: float) -> None: ], ) + async def test_unlock_door_waits_after_unlock_command(self): + backend = _make_backend(_FakeIO([])) + events = [] + + async def get_door_locked() -> bool: + events.append("get_door_locked") + return True + + async def send_safe(cmd: bytes, **kwargs): + del kwargs + events.append(cmd.hex()) + return b"" + + async def sleep(seconds: float) -> None: + events.append(("sleep", seconds)) + + backend.get_door_locked = get_door_locked + backend._send_safe = send_safe + + with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.asyncio.sleep", sleep): + await backend.unlock_door() + + self.assertEqual( + events, + [ + "get_door_locked", + "aa022600042c", + ("sleep", 0.5), + ], + ) + async def test_go_to_position_retries_after_wrong_idle_position(self): backend = _make_backend(_FakeIO([])) events = [] From 5119912c58bbae90feb85a445ba322edd926b6da Mon Sep 17 00:00:00 2001 From: TheBirdAttack Date: Wed, 1 Jul 2026 16:55:04 +0200 Subject: [PATCH 14/22] clean up --- README.md | 2 +- pylabrobot/centrifuge/vspin_backend.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fc0b3e92291..38b596013a5 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ await cf.setup() await cf.spin(g=800, duration=60) ``` -Use `variant="v11"` for legacy Velocity11 VSpin centrifuges. The legacy backend has not yet been validated on hardware in PLR. +Use `variant="velocity11"` for legacy Velocity11 VSpin centrifuges. For a HighRes Biosolutions MicroSpin, use the MicroSpin factory: diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index 0d7e3b4306e..ee8bcbe5fed 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -665,18 +665,18 @@ def create_vspin_backend( """Create a VSpin backend for the selected centrifuge generation. ``agilent`` keeps the existing PyLabRobot command path for newer - Agilent-branded VSpin centrifuges. ``v11``/``velocity11`` selects the legacy - Velocity11 path derived from old V11 hardware traces. The command sets should + Agilent-branded VSpin centrifuges. ``velocity11`` selects the legacy + Velocity11 path derived from old hardware traces. The command sets should not be treated as interchangeable until both generations are hardware-tested. """ normalized_variant = variant.lower() if normalized_variant == "agilent": return VSpinBackend(device_id=device_id) - if normalized_variant in ("v11", "velocity11", "legacy"): + if normalized_variant == "velocity11": from .v11_vspin_backend import V11VSpinBackend return V11VSpinBackend(device_id=device_id) - raise ValueError("variant must be 'agilent', 'v11', 'velocity11', or 'legacy'") + raise ValueError("variant must be 'agilent', or 'velocity11'") # Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) From a4fcb040b27f9ef8bd2f636f1ad70cb42eba5031 Mon Sep 17 00:00:00 2001 From: TheRealDarkLuke Date: Wed, 1 Jul 2026 17:00:01 +0200 Subject: [PATCH 15/22] Update agilent_vspin.ipynb --- .../01_material-handling/centrifuge/agilent_vspin.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb index 82874643fbf..66f3b361456 100644 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb +++ b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb @@ -9,7 +9,7 @@ "\n", "The Agilent-branded VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. It remains the default backend for newer Agilent systems.\n", "\n", - "Legacy Velocity11 units can use the {class}`~pylabrobot.centrifuge.v11_vspin_backend.V11VSpinBackend` class or the {func}`~pylabrobot.centrifuge.vspin_backend.create_vspin_backend` helper with `variant=\"v11\"`. The Velocity11 backend was ported from working code, but it has not yet been validated on hardware in PLR." + "Legacy Velocity11 units can use the {class}`~pylabrobot.centrifuge.v11_vspin_backend.V11VSpinBackend` class or the {func}`~pylabrobot.centrifuge.vspin_backend.create_vspin_backend` helper with `variant=\"velocity11"`. ] }, { From 1f4280047b45e10b39a76c918a48ee3e8aacc470 Mon Sep 17 00:00:00 2001 From: TheRealDarkLuke Date: Wed, 1 Jul 2026 17:18:41 +0200 Subject: [PATCH 16/22] Revert pylabrobot/io/ftdi.py to previous implementation (moved serial fallback to quick-patch-v11) --- pylabrobot/io/ftdi.py | 243 +++++------------------------------------- 1 file changed, 28 insertions(+), 215 deletions(-) diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index 49b1065fb85..7d931aef8ed 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -23,15 +23,6 @@ HAS_PYUSB = False _PYUSB_ERROR = e -try: - import serial - from serial.tools import list_ports - - HAS_PYSERIAL = True -except ImportError as e: - HAS_PYSERIAL = False - _PYSERIAL_ERROR = e - from pylabrobot.io.capture import CaptureReader, Command, capturer, get_capture_or_validation_active from pylabrobot.io.errors import ValidationError from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences @@ -91,7 +82,6 @@ def __init__( # Will be resolved in setup() self._dev: Optional[Device] = None - self._serial_dev: Optional["serial.Serial"] = None self._executor: Optional[ThreadPoolExecutor] = None if get_capture_or_validation_active(): @@ -105,12 +95,6 @@ def dev(self) -> "Device": raise RuntimeError("Device not initialized. Call setup() first.") return self._dev - @property - def serial_dev(self) -> "serial.Serial": - if self._serial_dev is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - return self._serial_dev - def _resolve_device_serial(self) -> str: """List connected FTDI devices and resolve which one to connect to based on parameters. @@ -194,90 +178,10 @@ def _resolve_device_serial(self) -> str: device_serial_number = cast(str, usb.util.get_string(device, device.iSerialNumber)) return device_serial_number - def _resolve_serial_port(self) -> tuple[str, str]: - if not HAS_PYSERIAL: - global _PYSERIAL_ERROR - raise RuntimeError( - "pyserial is not installed. Install with: pip install pylabrobot[serial]. " - f"Import error: {_PYSERIAL_ERROR}" - ) - - candidates = [] - connected_devices_list = [] - for port in list_ports.comports(): - serial_number = port.serial_number or "" - vid = port.vid - pid = port.pid - vid_pid = ( - f"{vid:04x}:{pid:04x}" if vid is not None and pid is not None else "unknown" - ) - connected_devices_list.append(f"{port.device} {serial_number} (VID:PID {vid_pid})") - - if vid is None or pid is None: - continue - if self._vid is not None and vid != self._vid: - continue - if self._vid is None and vid not in pylibftdi.driver.USB_VID_LIST: - continue - if self._pid is not None and pid != self._pid: - continue - if self._pid is None and pid not in pylibftdi.driver.USB_PID_LIST: - continue - - if self._device_id is not None: - if serial_number == self._device_id: - candidates.append((port.device, serial_number, True)) - continue - if serial_number.startswith(self._device_id): - candidates.append((port.device, serial_number, False)) - continue - continue - - candidates.append((port.device, serial_number, True)) - - exact_candidates = [candidate for candidate in candidates if candidate[2]] - if exact_candidates: - candidates = exact_candidates - - connected_devices_string = ", ".join(connected_devices_list) - vid_string = f"{self._vid:04x}" if self._vid is not None else "any" - pid_string = f"{self._pid:04x}" if self._pid is not None else "any" - - if len(candidates) == 0: - raise RuntimeError( - f"No FTDI serial ports found with specified criteria: " - f"VID:PID {vid_string}:{pid_string}, " - f"device_id {self._device_id}. " - "Connected serial ports: " + connected_devices_string - ) - - if len(candidates) > 1: - raise RuntimeError( - f"Multiple FTDI serial ports found with specified criteria: " - f"VID:PID {vid_string}:{pid_string}, " - f"device_id {self._device_id}. " - f"Please specify the device_id parameter explicitly with the serial number of the desired device." - ) - - port_name, serial_number, exact_match = candidates[0] - if not exact_match: - logger.warning( - "Resolved FTDI serial prefix %s to %s on %s", - self._device_id, - serial_number, - port_name, - ) - return port_name, serial_number - async def setup(self): """Initialize the FTDI device connection with device resolution.""" if self._dev is not None and not self._dev.closed: self._dev.close() - if self._serial_dev is not None and self._serial_dev.is_open: - self._serial_dev.close() - self._serial_dev = None - - native_error: Optional[Exception] = None try: # Resolve which device to connect to self._device_id = self._resolve_device_serial() @@ -292,31 +196,12 @@ async def setup(self): ) self._dev.open() logger.info(f"Successfully opened FTDI device: {self.device_id}") - except Exception as e: - native_error = e - - if self._dev is None: - try: - port_name, serial_number = self._resolve_serial_port() - self._device_id = serial_number - self._serial_dev = serial.Serial( - port=port_name, - timeout=0, - write_timeout=1, - ) - logger.info( - "Successfully opened FTDI serial port %s for device: %s", - port_name, - self.device_id, - ) - except Exception as serial_error: - raise RuntimeError( - f"Failed to open FTDI device for '{self._human_readable_device_name}'. " - f"pylibftdi error: {native_error}. " - f"pyserial fallback error: {serial_error}. " - "Is the device connected? Is it in use by another process? " - "Try restarting the kernel." - ) from serial_error + except FtdiError as e: + raise RuntimeError( + f"Failed to open FTDI device for '{self._human_readable_device_name}': {e}. " + "Is the device connected? Is it in use by another process? " + "Try restarting the kernel." + ) from e self._executor = ThreadPoolExecutor(max_workers=1) @@ -328,13 +213,7 @@ def device_id(self) -> str: async def set_baudrate(self, baudrate: int): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - await loop.run_in_executor( - self._executor, - lambda: setattr(self.serial_dev, "baudrate", baudrate), - ) - else: - await loop.run_in_executor(self._executor, lambda: setattr(self.dev, "baudrate", baudrate)) + await loop.run_in_executor(self._executor, lambda: setattr(self.dev, "baudrate", baudrate)) logger.log(LOG_LEVEL_IO, "[%s] set_baudrate %s", self._device_id, baudrate) capturer.record( FTDICommand(device_id=self.device_id, action="set_baudrate", data=str(baudrate)) @@ -342,35 +221,27 @@ async def set_baudrate(self, baudrate: int): async def set_rts(self, level: bool): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - await loop.run_in_executor(self._executor, lambda: setattr(self.serial_dev, "rts", level)) - else: - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setrts(level)) + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setrts(level)) logger.log(LOG_LEVEL_IO, "[%s] set_rts %s", self._device_id, level) capturer.record(FTDICommand(device_id=self.device_id, action="set_rts", data=str(level))) async def set_dtr(self, level: bool): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - await loop.run_in_executor(self._executor, lambda: setattr(self.serial_dev, "dtr", level)) - else: - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setdtr(level)) + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setdtr(level)) logger.log(LOG_LEVEL_IO, "[%s] set_dtr %s", self._device_id, level) capturer.record(FTDICommand(device_id=self.device_id, action="set_dtr", data=str(level))) async def usb_reset(self): loop = asyncio.get_running_loop() - if self._serial_dev is None: - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_reset()) + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_reset()) logger.log(LOG_LEVEL_IO, "[%s] usb_reset", self._device_id) capturer.record(FTDICommand(device_id=self.device_id, action="usb_reset", data="")) async def set_latency_timer(self, latency: int): loop = asyncio.get_running_loop() - if self._serial_dev is None: - await loop.run_in_executor( - self._executor, lambda: self.dev.ftdi_fn.ftdi_set_latency_timer(latency) - ) + await loop.run_in_executor( + self._executor, lambda: self.dev.ftdi_fn.ftdi_set_latency_timer(latency) + ) logger.log(LOG_LEVEL_IO, "[%s] set_latency_timer %s", self._device_id, latency) capturer.record( FTDICommand(device_id=self.device_id, action="set_latency_timer", data=str(latency)) @@ -378,27 +249,9 @@ async def set_latency_timer(self, latency: int): async def set_line_property(self, bits: int, stopbits: int, parity: int): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - parity_map = { - 0: serial.PARITY_NONE, - 1: serial.PARITY_ODD, - 2: serial.PARITY_EVEN, - } - stopbits_map = { - 1: serial.STOPBITS_ONE, - 2: serial.STOPBITS_TWO, - } - - def configure_serial_line(): - self.serial_dev.bytesize = bits - self.serial_dev.stopbits = stopbits_map.get(stopbits, stopbits) - self.serial_dev.parity = parity_map.get(parity, serial.PARITY_NONE) - - await loop.run_in_executor(self._executor, configure_serial_line) - else: - await loop.run_in_executor( - self._executor, lambda: self.dev.ftdi_fn.ftdi_set_line_property(bits, stopbits, parity) - ) + await loop.run_in_executor( + self._executor, lambda: self.dev.ftdi_fn.ftdi_set_line_property(bits, stopbits, parity) + ) logger.log( LOG_LEVEL_IO, "[%s] set_line_property %s,%s,%s", self._device_id, bits, stopbits, parity ) @@ -410,16 +263,7 @@ def configure_serial_line(): async def set_flowctrl(self, flowctrl: int): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - - def configure_serial_flow_control(): - self.serial_dev.xonxoff = False - self.serial_dev.rtscts = False - self.serial_dev.dsrdtr = False - - await loop.run_in_executor(self._executor, configure_serial_flow_control) - else: - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setflowctrl(flowctrl)) + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_setflowctrl(flowctrl)) logger.log(LOG_LEVEL_IO, "[%s] set_flowctrl %s", self._device_id, flowctrl) capturer.record( FTDICommand(device_id=self.device_id, action="set_flowctrl", data=str(flowctrl)) @@ -427,46 +271,27 @@ def configure_serial_flow_control(): async def usb_purge_rx_buffer(self): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - await loop.run_in_executor(self._executor, self.serial_dev.reset_input_buffer) - else: - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_rx_buffer()) + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_rx_buffer()) logger.log(LOG_LEVEL_IO, "[%s] usb_purge_rx_buffer", self._device_id) capturer.record(FTDICommand(device_id=self.device_id, action="usb_purge_rx_buffer", data="")) async def usb_purge_tx_buffer(self): loop = asyncio.get_running_loop() - if self._serial_dev is not None: - await loop.run_in_executor(self._executor, self.serial_dev.reset_output_buffer) - else: - await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_tx_buffer()) + await loop.run_in_executor(self._executor, lambda: self.dev.ftdi_fn.ftdi_usb_purge_tx_buffer()) logger.log(LOG_LEVEL_IO, "[%s] usb_purge_tx_buffer", self._device_id) capturer.record(FTDICommand(device_id=self.device_id, action="usb_purge_tx_buffer", data="")) async def poll_modem_status(self) -> int: loop = asyncio.get_running_loop() - if self._serial_dev is not None: - - def read_serial_modem_status() -> int: - status = 0 - status |= int(bool(self.serial_dev.cts)) << 4 - status |= int(bool(self.serial_dev.dsr)) << 5 - status |= int(bool(self.serial_dev.ri)) << 6 - status |= int(bool(self.serial_dev.cd)) << 7 - return status - - value = await loop.run_in_executor(self._executor, read_serial_modem_status) - else: - stat = ctypes.c_ushort(0) - await loop.run_in_executor( - self._executor, lambda: self.dev.ftdi_fn.ftdi_poll_modem_status(ctypes.byref(stat)) - ) - value = stat.value - logger.log(LOG_LEVEL_IO, "[%s] poll_modem_status %s", self._device_id, value) + stat = ctypes.c_ushort(0) + await loop.run_in_executor( + self._executor, lambda: self.dev.ftdi_fn.ftdi_poll_modem_status(ctypes.byref(stat)) + ) + logger.log(LOG_LEVEL_IO, "[%s] poll_modem_status %s", self._device_id, stat.value) capturer.record( - FTDICommand(device_id=self.device_id, action="poll_modem_status", data=str(value)) + FTDICommand(device_id=self.device_id, action="poll_modem_status", data=str(stat.value)) ) - return value + return stat.value async def get_serial(self) -> str: return self.device_id @@ -474,10 +299,6 @@ async def get_serial(self) -> str: async def stop(self): if self._dev is not None: self.dev.close() - self._dev = None - if self._serial_dev is not None: - self.serial_dev.close() - self._serial_dev = None if self._executor is not None: self._executor.shutdown(wait=True) self._executor = None @@ -486,15 +307,10 @@ async def write(self, data: bytes) -> int: """Write data to the device. Returns the number of bytes written.""" logger.log(LOG_LEVEL_IO, "[%s] write %s", self._device_id, data) capturer.record(FTDICommand(device_id=self.device_id, action="write", data=data.hex())) - if self._serial_dev is not None: - return cast(int, self.serial_dev.write(data)) return cast(int, self.dev.write(data)) async def read(self, num_bytes: int = 1) -> bytes: - if self._serial_dev is not None: - data = self.serial_dev.read(num_bytes) - else: - data = self.dev.read(num_bytes) + data = self.dev.read(num_bytes) if len(data) != 0: logger.log(LOG_LEVEL_IO, "[%s] read %s", self._device_id, data) capturer.record( @@ -507,10 +323,7 @@ async def read(self, num_bytes: int = 1) -> bytes: return cast(bytes, data) async def readline(self) -> bytes: # type: ignore # very dumb it's reading from pyserial - if self._serial_dev is not None: - data = self.serial_dev.readline() - else: - data = self.dev.readline() + data = self.dev.readline() if len(data) != 0: logger.log(LOG_LEVEL_IO, "[%s] readline %s", self._device_id, data) capturer.record(FTDICommand(device_id=self.device_id, action="readline", data=data.hex())) From 6c2dfb6f6e6682b9e005beaf5137cae7ee3f9fa1 Mon Sep 17 00:00:00 2001 From: TheRealDarkLuke Date: Wed, 1 Jul 2026 17:19:34 +0200 Subject: [PATCH 17/22] Update ftdi optional dependencies Removed 'pyserial' from the 'ftdi' optional dependency list. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 302a27f0d2d..717e28ccb6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = ["typing_extensions", "websockets"] [project.optional-dependencies] serial = ["pyserial"] usb = ["pyusb", "libusb-package"] -ftdi = ["pylibftdi", "pyusb", "pyserial"] +ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.1"] From 2016c420b0a7484690a37d719bcf27f162c6f3db Mon Sep 17 00:00:00 2001 From: lucas vogel Date: Thu, 2 Jul 2026 00:07:25 +0200 Subject: [PATCH 18/22] Quick conversion by codex - to be reviewed --- .../centrifuge/agilent_vspin.ipynb | 2 +- pylabrobot/centrifuge/__init__.py | 1 - pylabrobot/centrifuge/v11_vspin_backend.py | 1327 ---------------- pylabrobot/centrifuge/vspin_backend.py | 1389 ++++++++++++++--- pylabrobot/centrifuge/vspin_backend_tests.py | 77 +- 5 files changed, 1212 insertions(+), 1584 deletions(-) delete mode 100644 pylabrobot/centrifuge/v11_vspin_backend.py diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb index 66f3b361456..c93a848d54f 100644 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb +++ b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb @@ -9,7 +9,7 @@ "\n", "The Agilent-branded VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. It remains the default backend for newer Agilent systems.\n", "\n", - "Legacy Velocity11 units can use the {class}`~pylabrobot.centrifuge.v11_vspin_backend.V11VSpinBackend` class or the {func}`~pylabrobot.centrifuge.vspin_backend.create_vspin_backend` helper with `variant=\"velocity11"`. + "Legacy Velocity11 units use the same {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class through the {func}`~pylabrobot.centrifuge.vspin_backend.create_vspin_backend` helper with `variant=\\\"velocity11\\\"`." ] }, { diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index 4544a56729d..0390308e042 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -14,5 +14,4 @@ LoaderNoPlateError, NotAtBucketError, ) -from .v11_vspin_backend import V11VSpinBackend from .vspin_backend import Access2Backend, VSpinBackend, create_vspin_backend diff --git a/pylabrobot/centrifuge/v11_vspin_backend.py b/pylabrobot/centrifuge/v11_vspin_backend.py deleted file mode 100644 index c8c0c0ad871..00000000000 --- a/pylabrobot/centrifuge/v11_vspin_backend.py +++ /dev/null @@ -1,1327 +0,0 @@ -import asyncio -import ctypes -import json -import logging -import os -import time -import warnings -from typing import Optional - -from pylabrobot.io.ftdi import FTDI - -from .backend import CentrifugeBackend - -logger = logging.getLogger(__name__) - - -_vspin_bucket_calibrations_path = os.path.join( - os.path.expanduser("~"), - ".pylabrobot", - "vspin_bucket_calibrations.json", -) - - -def _load_vspin_calibrations(device_id: str) -> Optional[int]: - if not os.path.exists(_vspin_bucket_calibrations_path): - warnings.warn( - f"No calibration found for VSpin with device id {device_id}. " - f"Using the default bucket 1 offset of home + {DEFAULT_BUCKET_1_OFFSET} ticks. " - "Use `set_bucket_1_position_to_current` after setup to override it.", - UserWarning, - ) - return None - with open(_vspin_bucket_calibrations_path, "r") as f: - return json.load(f).get(device_id) # type: ignore - - -def _save_vspin_calibrations(device_id, remainder: int): - if os.path.exists(_vspin_bucket_calibrations_path): - with open(_vspin_bucket_calibrations_path, "r") as f: - data = json.load(f) - else: - data = {} - data[device_id] = remainder - os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) - with open(_vspin_bucket_calibrations_path, "w") as f: - json.dump(data, f) - - -FULL_ROTATION: int = 8000 -DEFAULT_BUCKET_1_OFFSET: int = 5337 -DEFAULT_BUCKET_1_REMAINDER: int = -DEFAULT_BUCKET_1_OFFSET -DEFAULT_READ_TIMEOUT: float = 0.2 -COMMAND_GAP_SECONDS: float = 0.05 -CONTROLLER_CONNECT_SETTLE_SECONDS: float = 2.0 -INITIALIZE_PACKET_GAP_SECONDS: float = 0.01 -STATUS_POLL_INTERVAL: float = 0.15 -POSITION_TOLERANCE: int = 15 -POSITION_SETTLE_TOLERANCE: int = 200 -POSITION_MOVE_ATTEMPTS: int = 2 -SPIN_START_ATTEMPTS: int = 2 -HOMING_TIMEOUT: float = 60.0 -HOMING_MIN_WAIT_SECONDS: float = 1.0 -TACH_TO_RPM: float = -14.69320388 -DOOR_OPEN_SETTLE_SECONDS: float = 2.0 -DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 -DOOR_CLOSE_SETTLE_SECONDS: float = 1.0 -PNEUMATIC_SETTLE_SECONDS: float = 0.35 - -_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x0B, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} -_IDLE_VSPIN_STATUSES = {0x09, 0x0B, 0x11, 0x89, 0x91} - - -def _with_vspin_checksum(cmd: bytes) -> bytes: - """Return ``cmd`` with the final VSpin checksum byte recomputed.""" - if len(cmd) <= 2 or cmd[0] != 0xAA: - return cmd - payload = cmd[1:-1] - return b"\xaa" + payload + bytes([sum(payload) & 0xFF]) - - -def _build_vspin_position_command(position: int) -> bytes: - position_bytes = int(position).to_bytes(4, byteorder="little") - payload = b"\x01\xd4\x97" + position_bytes + bytes.fromhex("c3f52800d71a0000") - return _with_vspin_checksum(b"\xaa" + payload + b"\x00") - - -def _build_vspin_spin_command( - current_position: int, - rpm: int, - duration: float, - acceleration: float, -) -> tuple[bytes, int]: - ticks_per_second = (int(rpm) / 60.0) * FULL_ROTATION - acceleration_ticks_per_second2 = 12903.2 * float(acceleration) - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - distance_at_speed = ticks_per_second * float(duration) - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - position_bytes = final_position.to_bytes(4, byteorder="little") - rpm_bytes = int(int(rpm) * 4473.925).to_bytes(4, byteorder="little") - acceleration_bytes = int(9.15 * 100 * float(acceleration)).to_bytes( - 4, byteorder="little" - ) - payload = b"\x01\xd4\x97" + position_bytes + rpm_bytes + acceleration_bytes - return _with_vspin_checksum(b"\xaa" + payload + b"\x00"), final_position - - -def _build_vspin_deceleration_command(deceleration: float) -> bytes: - deceleration_bytes = int(9.15 * 100 * float(deceleration)).to_bytes( - 2, byteorder="little" - ) - return _with_vspin_checksum( - bytes.fromhex("aa0194b600000000") + deceleration_bytes + b"\x00\x00\x00" - ) - - -def _normalize_vspin_home_position(home_position: int) -> int: - return int(home_position) % FULL_ROTATION - - -def _vspin_position_matches_target(position: int, target: int, tolerance: int) -> bool: - absolute_delta = abs(int(position) - int(target)) - if absolute_delta <= tolerance: - return True - - angular_delta = abs((int(position) % FULL_ROTATION) - (int(target) % FULL_ROTATION)) - angular_delta = min(angular_delta, FULL_ROTATION - angular_delta) - return angular_delta <= tolerance - - -bucket_1_not_set_error = RuntimeError( - "Bucket 1 position not set. " - "Please rotate the bucket to bucket 1 using V11VSpinBackend.go_to_position and " - "then calling V11VSpinBackend.set_bucket_1_position_to_current." -) - - -class V11VSpinBackend(CentrifugeBackend): - """Backend for legacy Velocity11 VSpin centrifuges. - - Velocity11 was acquired by Agilent, but this legacy command path is kept - separate from the Agilent VSpin backend because the startup and polling - behavior observed on older Velocity11 units is not known to be compatible - with newer Agilent-branded centrifuges. - - Hardware validation is still pending for this port; test on real Velocity11 - hardware before relying on it in production. - """ - - def __init__( - self, - device_id: Optional[str] = None, - try_runtime_attach_after_startup_failure: bool = False, - ): - """ - Args: - device_id: The libftdi id for the centrifuge. - Find using `python -m pylibftdi.examples.list_devices`. - try_runtime_attach_after_startup_failure: Try to attach to a controller - that is already in 57600-baud runtime mode before cold startup, and - retry that attach path if the normal 19200-baud startup handshake - fails. This is disabled by default because some - legacy controllers are sensitive to extra startup probes. - """ - self.io = FTDI(human_readable_device_name="Velocity11 VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = DEFAULT_BUCKET_1_REMAINDER - self._last_position: int = 0 - self._last_home_position: int = 0 - self._home_sensor_position: Optional[int] = None - self._motion_is_prepared = False - self._stop_requested = False - self._command_lock: Optional[asyncio.Lock] = None - self._last_command_at = 0.0 - self._try_runtime_attach_after_startup_failure = try_runtime_attach_after_startup_failure - # only attempt loading calibration if device_id is not None - # if it is None, we will load it after setup when we can query the device id from the io - if device_id is not None: - calibration = _load_vspin_calibrations(device_id) - self._bucket_1_remainder = ( - calibration if calibration is not None else DEFAULT_BUCKET_1_REMAINDER - ) - - async def setup(self): - await self.io.setup() - try: - self._command_lock = asyncio.Lock() - self._motion_is_prepared = False - - attached_to_runtime = False - if self._try_runtime_attach_after_startup_failure: - attached_to_runtime = await self._try_attach_to_runtime_controller() - if attached_to_runtime: - logger.info("[vspin] Attached to runtime controller before cold startup") - - if not attached_to_runtime: - await self.configure_and_initialize() - try: - await self._startup_handshake() - await self._enable_telemetry_and_pneumatics() - except TimeoutError as e: - if ( - self._try_runtime_attach_after_startup_failure - and await self._try_attach_to_runtime_controller() - ): - logger.info("[vspin] Recovered controller after partial startup") - else: - raise TimeoutError( - "VSpin did not respond to the 19200-baud startup handshake. " - "Power-cycle or restart the VSpin controller, then try setup again." - ) from e - - await self._home_rotor() - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder == DEFAULT_BUCKET_1_REMAINDER: - device_id = await self.io.get_serial() - calibration = _load_vspin_calibrations(device_id) - self._bucket_1_remainder = ( - calibration if calibration is not None else DEFAULT_BUCKET_1_REMAINDER - ) - except Exception: - await self._close_connection_cleanly() - raise - - @property - def bucket_1_remainder(self) -> int: - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - return self._bucket_1_remainder - - async def set_bucket_1_position_to_current(self) -> None: - """Set the current position as bucket 1 position and save calibration.""" - current_position = await self.get_position() - device_id = await self.io.get_serial() - home_sensor_position = ( - self._home_sensor_position - if self._home_sensor_position is not None - else await self.get_home_position() - ) - home_sensor_position = _normalize_vspin_home_position(home_sensor_position) - home_rotations = (current_position - home_sensor_position) // FULL_ROTATION - home_position = home_sensor_position + home_rotations * FULL_ROTATION - remainder = home_position - current_position - self._bucket_1_remainder = remainder - _save_vspin_calibrations(device_id, remainder) - - async def get_bucket_1_position(self) -> int: - """Get the bucket 1 position based on calibration. - Normally it is the home position minus the remainder (calibration). - The bucket 1 position must be greater than the current position, so we find - the first position greater than the current position by adding full rotations if needed. - """ - return await self._get_bucket_position(1) - - async def _get_bucket_position(self, bucket_num: int) -> int: - if bucket_num not in (1, 2): - raise ValueError("bucket_num must be 1 or 2") - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - - home_position = self._home_sensor_position - if home_position == 0: - home_position = None - if home_position is None: - live_home_position = await self.get_home_position() - if live_home_position == 0: - await self._home_rotor() - home_position = self._home_sensor_position - if home_position == 0: - home_position = None - else: - home_position = live_home_position - - if home_position is None: - raise RuntimeError("VSpin home sensor position is unknown. Run setup or _home_rotor first.") - - current_position = await self.get_position() - target_position = home_position - self.bucket_1_remainder - if bucket_num == 2: - target_position += FULL_ROTATION // 2 - - while target_position <= current_position + POSITION_TOLERANCE: - target_position += FULL_ROTATION - - return target_position - - async def stop(self): - await self._close_connection_cleanly() - - class _StatusPositionTachometer(ctypes.LittleEndianStructure): - _pack_ = 1 - _fields_ = [ - ("status", ctypes.c_uint8), - ("current_position", ctypes.c_uint32), - ("unknown1", ctypes.c_uint8), - ("tachometer", ctypes.c_int16), - ("unknown2", ctypes.c_uint8), - ("home_position", ctypes.c_uint32), - ("checksum", ctypes.c_uint8), - ] - - @staticmethod - def _make_status( - status: int, - current_position: int, - unknown1: int = 0, - tachometer: int = 0, - unknown2: int = 0, - home_position: int = 0, - checksum: int = 0, - ) -> _StatusPositionTachometer: - parsed = V11VSpinBackend._StatusPositionTachometer() - parsed.status = status - parsed.current_position = current_position - parsed.unknown1 = unknown1 - parsed.tachometer = tachometer - parsed.unknown2 = unknown2 - parsed.home_position = home_position - parsed.checksum = checksum - return parsed - - @staticmethod - def _find_status_packet(resp: bytes) -> Optional[_StatusPositionTachometer]: - for start in range(max(0, len(resp) - 13)): - packet = resp[start : start + 14] - if len(packet) < 14 or packet[0] not in _KNOWN_VSPIN_STATUSES: - continue - if (sum(packet[:-1]) & 0xFF) != packet[-1]: - continue - return V11VSpinBackend._StatusPositionTachometer.from_buffer_copy(packet) - return None - - @staticmethod - def _find_short_status(resp: bytes) -> Optional[int]: - if len(resp) == 5 and resp[0] == 0x00 and (sum(resp[:-1]) & 0xFF) == resp[-1]: - if resp[2] in _KNOWN_VSPIN_STATUSES: - return resp[2] - - for index, value in enumerate(resp): - if value not in _KNOWN_VSPIN_STATUSES: - continue - if len(resp) == 1: - return value - if index + 1 < len(resp) and resp[index + 1] == value: - return value - return None - - def _parse_position_status(self, resp: bytes) -> _StatusPositionTachometer: - full_status = self._find_status_packet(resp) - if full_status is not None: - return full_status - - short_status = self._find_short_status(resp) - if short_status is not None: - return self._make_status( - status=short_status, - current_position=self._last_position, - home_position=self._last_home_position, - ) - - return self._make_status( - status=0x19, - current_position=self._last_position, - home_position=self._last_home_position, - ) - - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - """Returns 14 bytes - - Example: - 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 - ^^ checksum - ^^ ^^ ^^ ^^ home position - ^^ ? (probably binary status objects) - ^^ ^^ tachometer - ^^ ? (probably binary status objects) - ^^ ^^ ^^ ^^ current position - ^^ - - First byte (index 0): - - 11 = 0b0001011 = idle - - 13 = 0b0001101 = unknown - - 08 = 0b0001000 = spinning - - 09 = 0b0001001 = also spinning but different - - 19 = 0b0010011 = unknown - - 88 = 0b1011000 = unknown - - 89 = 0b1011001 = unknown - - 10th to 13th byte (index 9-12) = Homing Position - - Last byte (index 13) = checksum - """ - resp = await self._send_command(bytes.fromhex("aa010e0f"), expected_len=14) - status = self._parse_position_status(resp) - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - return status - - async def _get_full_positions_and_tachometer(self) -> _StatusPositionTachometer: - resp = await self._send_command( - bytes.fromhex("aa01121f32"), - read_timeout=0.40, - expected_len=14, - ) - status = self._parse_position_status(resp) - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - return status - - async def get_position(self) -> int: - return (await self._get_positions_and_tachometer()).current_position # type: ignore - - async def get_tachometer(self) -> int: - """current speed in rpm""" - return (await self._get_positions_and_tachometer()).tachometer * TACH_TO_RPM # type: ignore - - async def get_home_position(self) -> int: - """changes during a run, but the bucket 1 position relative to it does not""" - return (await self._get_positions_and_tachometer()).home_position # type: ignore - - async def _get_status(self): - """ - examples: - - 0080d0015 - - 0080f0015 - """ - - resp = await self._send_command(bytes.fromhex("aa020e10"), expected_len=5) - if len(resp) < 3: - raise IOError(f"Invalid status from centrifuge: {resp.hex() or '(empty)'}") - return resp - - async def get_bucket_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0001 != 0 # type: ignore - - async def get_door_open(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0010 != 0 # type: ignore - - async def get_door_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0100 == 0 # type: ignore - - # Centrifuge communication: read_resp, send - - async def _read_resp( - self, - timeout: float = DEFAULT_READ_TIMEOUT, - expected_len: Optional[int] = None, - quiet_time: float = 0.05, - ) -> bytes: - """Read raw binary VSpin responses. - - VSpin status replies are usually 2, 5, or 14 raw bytes and do not end in - CR. Waiting for 0x0d creates avoidable timeouts on normal status polling. - """ - data = b"" - start_time = time.monotonic() - last_data_time: Optional[float] = None - - while time.monotonic() - start_time < timeout: - chunk = await self.io.read(25) - if chunk: - data += bytes(chunk) - last_data_time = time.monotonic() - if expected_len is not None and len(data) >= expected_len: - break - continue - - if ( - data - and expected_len is None - and last_data_time is not None - and time.monotonic() - last_data_time >= quiet_time - ): - break - - await asyncio.sleep(0.003) - - logger.debug("[vspin] Read %s", data.hex()) - return data - - async def _send_safe( - self, - cmd: bytes, - retries: int = 3, - timeout: float = DEFAULT_READ_TIMEOUT, - expect_response: bool = True, - expected_len: Optional[int] = None, - ) -> bytes: - for attempt in range(1, retries + 1): - resp = await self._send_command( - cmd, - read_timeout=timeout, - expected_len=expected_len, - ) - if resp or not expect_response: - return resp - - logger.debug( - "[vspin] Empty response to %s (attempt %d/%d)", - cmd.hex(), - attempt, - retries, - ) - await asyncio.sleep(0.15) - - raise TimeoutError(f"No response to VSpin command {cmd.hex()}") - - @staticmethod - def _expected_response_len(cmd: bytes) -> Optional[int]: - if cmd == bytes.fromhex("aa010e0f"): - return 14 - if cmd == bytes.fromhex("aa020e10"): - return 5 - if cmd in (bytes.fromhex("aa002101ff21"), bytes.fromhex("aa002102ff22")): - return 2 - if cmd in (bytes.fromhex("aa01132034"), bytes.fromhex("aa02132035")): - return 4 - return None - - def _get_command_lock(self) -> asyncio.Lock: - if self._command_lock is None: - self._command_lock = asyncio.Lock() - return self._command_lock - - async def _send_command( - self, - cmd: bytes, - read_timeout: float = DEFAULT_READ_TIMEOUT, - expected_len: Optional[int] = None, - ) -> bytes: - cmd = _with_vspin_checksum(bytes(cmd)) - expected_len = expected_len or self._expected_response_len(cmd) - lock = self._get_command_lock() - - logger.debug("[vspin] Sending %s", cmd.hex()) - async with lock: - command_gap = COMMAND_GAP_SECONDS - (time.monotonic() - self._last_command_at) - if command_gap > 0: - await asyncio.sleep(command_gap) - written = await self.io.write(cmd) - if written != len(cmd): - raise RuntimeError(f"Failed to write all bytes ({written}/{len(cmd)} bytes written)") - resp = await self._read_resp(timeout=read_timeout, expected_len=expected_len) - self._last_command_at = time.monotonic() - - logger.debug("[vspin] Response %s", resp.hex()) - return resp - - async def _startup_handshake(self) -> None: - await self._send_safe(bytes.fromhex("aa002101ff21"), timeout=0.60, expected_len=2) - await self._send_safe(bytes.fromhex("aa01132034"), timeout=0.60, expected_len=4) - await self._send_safe(bytes.fromhex("aa002102ff22"), timeout=0.60, expected_len=2) - await self._send_safe(bytes.fromhex("aa02132035"), timeout=0.60, expected_len=4) - - # The original software writes this and then tolerates silence for roughly - # two seconds while the controller transitions into its startup state. - await self._send_safe( - bytes.fromhex("aa002103ff23"), - timeout=0.15, - expect_response=False, - ) - await self._drain_startup_silence(2.0) - - await self._send_safe(bytes.fromhex("aaff1a142d"), timeout=0.12, expect_response=False) - await self._send_safe( - bytes.fromhex("aa010e0f"), - timeout=0.30, - expected_len=None, - expect_response=False, - ) - await self._send_safe( - bytes.fromhex("aa020e10"), - timeout=0.30, - expected_len=5, - expect_response=False, - ) - - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - async def _drain_startup_silence(self, seconds: float) -> None: - end = time.monotonic() + seconds - while time.monotonic() < end: - await self._read_resp(timeout=0.08, expected_len=None, quiet_time=0.01) - await asyncio.sleep(0.03) - - @staticmethod - def _is_runtime_attach_status(status: _StatusPositionTachometer) -> bool: - return int(status.current_position) != 0 or int(status.home_position) != 0 - - async def _try_attach_to_runtime_controller(self) -> bool: - """Attach to a VSpin controller that is already in 57600-baud runtime mode.""" - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - await self._purge_io_buffers() - await self._drain_startup_silence(0.25) - - for _ in range(2): - for status_command in ( - bytes.fromhex("aa010e0f"), - bytes.fromhex("aa01121f32"), - ): - resp = await self._send_command( - status_command, - read_timeout=0.40, - expected_len=14, - ) - status = self._find_status_packet(resp) - if status is not None: - if not self._is_runtime_attach_status(status): - logger.debug( - "[vspin] Ignoring blank runtime attach status " - "(status=0x%02x, position=%d, home=%d)", - status.status, - status.current_position, - status.home_position, - ) - continue - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - logger.info( - "[vspin] Attached to runtime controller " - "(status=0x%02x, position=%d, home=%d)", - status.status, - status.current_position, - status.home_position, - ) - return True - await asyncio.sleep(0.20) - - return False - - async def _enable_telemetry_and_pneumatics(self) -> None: - await self._send_safe(bytes.fromhex("aa01121f32"), timeout=0.35, expected_len=14) - - for _ in range(8): - await self._send_safe(bytes.fromhex("aa0220ff0f30"), timeout=0.12) - - for cmd in ( - bytes.fromhex("aa0220df0f10"), - bytes.fromhex("aa0220df0e0f"), - bytes.fromhex("aa0220df0c0d"), - bytes.fromhex("aa0220df0809"), - ): - await self._send_safe(cmd, timeout=0.12) - - for _ in range(4): - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.12) - - await self._send_safe(bytes.fromhex("aa02120317"), timeout=0.12) - - for _ in range(5): - await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.15) - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) - - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) - await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.15) - await self._poll_io_status(0.35) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) - await self._poll_io_status(0.35) - self._motion_is_prepared = True - - async def _poll_io_status(self, seconds: float) -> None: - end = time.monotonic() + seconds - while time.monotonic() < end: - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.10, expected_len=5) - await asyncio.sleep(0.12) - - async def _motor_enable(self) -> None: - await self._send_safe(bytes.fromhex("aa0117021a"), timeout=0.30, expected_len=14) - await self._send_safe( - bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), - timeout=0.30, - expected_len=14, - ) - await self._send_safe(bytes.fromhex("aa0117041c"), timeout=0.30, expected_len=14) - await self._send_safe(bytes.fromhex("aa01170119"), timeout=0.30, expected_len=14) - await self._send_safe(bytes.fromhex("aa010b0c"), timeout=0.30, expected_len=14) - - async def _home_rotor(self) -> None: - pre_home_status = await self._get_positions_and_tachometer() - homing_started_at = time.monotonic() - await self._motor_enable() - await self._send_safe(bytes.fromhex("aa010001"), timeout=0.30, expected_len=14) - await self._send_safe( - bytes.fromhex("aa01e605006400000000003200e80301006e"), - timeout=0.30, - expected_len=14, - ) - await self._send_safe( - bytes.fromhex("aa0194b61283000012010000f3"), - timeout=0.30, - expected_len=14, - ) - await self._send_safe(bytes.fromhex("aa01192842"), timeout=0.30, expected_len=14) - - status = await self._wait_for_idle( - label="homing", - timeout=HOMING_TIMEOUT, - min_wait=HOMING_MIN_WAIT_SECONDS, - require_activity_from=pre_home_status, - activity_tolerance=POSITION_SETTLE_TOLERANCE, - ) - - if not self._has_fresh_home_reference( - status=status, - previous_home_position=int(pre_home_status.home_position), - ): - remaining_timeout = max(0.0, HOMING_TIMEOUT - (time.monotonic() - homing_started_at)) - logger.debug( - "[vspin] Waiting for fresh homing reference " - "(previous home=%d, current home=%d)", - pre_home_status.home_position, - status.home_position, - ) - status = await self._wait_for_homed_status( - pre_home_status=pre_home_status, - timeout=remaining_timeout, - ) - - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - self._home_sensor_position = _normalize_vspin_home_position(status.home_position) - - @staticmethod - def _has_fresh_home_reference( - status: _StatusPositionTachometer, - previous_home_position: int, - ) -> bool: - if int(status.home_position) == 0: - return False - if int(previous_home_position) == 0: - return True - return int(status.home_position) != int(previous_home_position) - - async def _wait_for_homed_status( - self, - pre_home_status: _StatusPositionTachometer, - timeout: float, - ) -> _StatusPositionTachometer: - end = time.monotonic() + timeout - previous_home_position = int(pre_home_status.home_position) - last_status: Optional[V11VSpinBackend._StatusPositionTachometer] = None - - while time.monotonic() < end: - status = await self._get_full_positions_and_tachometer() - last_status = status - is_idle_status = status.status in _IDLE_VSPIN_STATUSES - is_stopped = abs(status.tachometer) <= 2 - if ( - is_idle_status - and is_stopped - and self._has_fresh_home_reference(status, previous_home_position) - ): - return status - await asyncio.sleep(STATUS_POLL_INTERVAL) - - if last_status is None: - last_status = self._make_status( - 0x19, - self._last_position, - home_position=self._last_home_position, - ) - raise TimeoutError( - "VSpin homing did not report a fresh home position within " - f"{timeout:.1f}s (previous home={previous_home_position}, " - f"last status=0x{last_status.status:02x}, " - f"position={last_status.current_position}, " - f"tachometer={last_status.tachometer}, " - f"home={last_status.home_position})" - ) - - async def _wait_for_full_status( - self, - timeout: float, - allow_zero_home_fallback: bool = False, - ) -> _StatusPositionTachometer: - end = time.monotonic() + timeout - last_raw = b"" - last_full_status: Optional[V11VSpinBackend._StatusPositionTachometer] = None - while time.monotonic() < end: - resp = await self._send_command( - bytes.fromhex("aa010e0f"), - read_timeout=0.40, - expected_len=14, - ) - last_raw = resp - status = self._find_status_packet(resp) - if status is not None and status.home_position != 0: - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - return status - if status is not None: - last_full_status = status - await asyncio.sleep(0.10) - - if allow_zero_home_fallback and last_full_status is not None: - self._last_position = int(last_full_status.current_position) - self._last_home_position = int(last_full_status.home_position) - return last_full_status - - raise TimeoutError( - "VSpin homing reached idle, but no full 14-byte status packet with a " - f"home position was received. Last raw response: {last_raw.hex() or '(empty)'}" - ) - - async def _wait_for_idle( - self, - label: str, - timeout: float, - target_position: Optional[int] = None, - tolerance: int = POSITION_TOLERANCE, - min_wait: float = 0.0, - require_activity_from: Optional[_StatusPositionTachometer] = None, - activity_tolerance: int = POSITION_TOLERANCE, - ) -> _StatusPositionTachometer: - start = time.monotonic() - last_status = self._make_status( - 0x19, - self._last_position, - home_position=self._last_home_position, - ) - observed_activity = require_activity_from is None - - while time.monotonic() - start <= timeout: - status = await self._get_positions_and_tachometer() - last_status = status - is_idle_status = status.status in _IDLE_VSPIN_STATUSES - is_stopped = abs(status.tachometer) <= 2 - should_probe_full_status = is_idle_status and is_stopped and ( - (require_activity_from is not None and not observed_activity) - or ( - target_position is not None - and not _vspin_position_matches_target( - position=int(status.current_position), - target=int(target_position), - tolerance=tolerance, - ) - ) - ) - if should_probe_full_status: - full_status = await self._get_full_positions_and_tachometer() - if full_status.status in _IDLE_VSPIN_STATUSES and abs(full_status.tachometer) <= 2: - status = full_status - last_status = status - is_idle_status = True - is_stopped = True - - if require_activity_from is not None and not observed_activity: - observed_activity = ( - not is_idle_status - or not is_stopped - or not _vspin_position_matches_target( - position=int(status.current_position), - target=int(require_activity_from.current_position), - tolerance=activity_tolerance, - ) - or ( - int(status.home_position) != 0 - and int(status.home_position) != int(require_activity_from.home_position) - ) - ) - is_at_target = ( - target_position is None - or _vspin_position_matches_target( - position=int(status.current_position), - target=int(target_position), - tolerance=tolerance, - ) - ) - - if ( - is_idle_status - and is_stopped - and is_at_target - and observed_activity - and time.monotonic() - start >= min_wait - ): - return status - - await asyncio.sleep(STATUS_POLL_INTERVAL) - raise TimeoutError( - f"VSpin {label} did not become idle within {timeout:.1f}s " - f"(status=0x{last_status.status:02x}, position={last_status.current_position}, " - f"tachometer={last_status.tachometer}, home={last_status.home_position})" - ) - - async def _prepare_bucket_motion(self) -> None: - await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.20) - await self._wait_for_io_state( - label="bucket motion lock", - timeout=1.5, - door_open=False, - door_locked=True, - bucket_locked=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._wait_for_io_state( - label="bucket motion ready", - timeout=2.5, - door_open=False, - door_locked=True, - bucket_locked=False, - settled=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True - - async def _prepare_spin_motion(self) -> None: - await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.20) - await self._wait_for_io_state( - label="spin bucket lock", - timeout=1.5, - door_open=False, - door_locked=True, - bucket_locked=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._wait_for_io_state( - label="spin ready", - timeout=2.5, - door_open=False, - door_locked=True, - bucket_locked=False, - settled=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True - - async def _wait_for_io_state( - self, - label: str, - timeout: float, - door_open: Optional[bool] = None, - door_locked: Optional[bool] = None, - bucket_locked: Optional[bool] = None, - settled: bool = False, - ) -> None: - end = time.monotonic() + timeout - last_status = b"" - while time.monotonic() < end: - try: - last_status = await self._get_status() - except IOError: - await asyncio.sleep(0.05) - continue - - if len(last_status) >= 3: - value = last_status[2] - matches = [] - if door_open is not None: - matches.append((value & 0b0010 != 0) is door_open) - if door_locked is not None: - matches.append((value & 0b0100 == 0) is door_locked) - if bucket_locked is not None: - matches.append((value & 0b0001 != 0) is bucket_locked) - if settled: - matches.append(last_status[1] in (0x00, 0x10)) - if all(matches): - return - - await asyncio.sleep(0.05) - - raise TimeoutError( - f"VSpin {label} IO state was not reached within {timeout:.1f}s " - f"(last status={last_status.hex() or '(empty)'})" - ) - - async def _send_deceleration(self, deceleration: float) -> None: - await self._send_safe( - bytes.fromhex("aa01e60500640000000000fd00803e01000c"), - timeout=0.25, - ) - await self._send_safe(_build_vspin_deceleration_command(deceleration), timeout=0.25) - - async def _wait_for_door(self, open_expected: bool, timeout: float) -> None: - end = time.monotonic() + timeout - while time.monotonic() < end: - try: - if await self.get_door_open() is open_expected: - return - except IOError: - pass - await asyncio.sleep(0.12) - - expected = "open" if open_expected else "closed" - raise TimeoutError(f"VSpin door did not report {expected} within {timeout:.1f}s") - - async def _wait_for_speed_or_motion( - self, - rpm: int, - final_position: int, - timeout: float = 25.0, - ) -> None: - deadline = time.monotonic() + timeout - last_status = self._make_status(0x19, self._last_position, home_position=self._last_home_position) - last_live_rpm = 0.0 - while time.monotonic() < deadline and not self._stop_requested: - status = await self._get_positions_and_tachometer() - last_status = status - last_live_rpm = abs(status.tachometer * TACH_TO_RPM) - if last_live_rpm >= rpm * 0.92: - return - await asyncio.sleep(0.25) - - raise TimeoutError( - f"VSpin did not reach target speed {rpm} rpm within {timeout:.1f}s " - f"(last rpm={last_live_rpm:.1f}, position={last_status.current_position}, " - f"home={last_status.home_position})" - ) - - async def _hold_spin(self, duration: float) -> None: - started = time.monotonic() - while not self._stop_requested and time.monotonic() - started < duration: - await self._get_positions_and_tachometer() - await asyncio.sleep(min(1.0, duration)) - - async def configure_and_initialize(self): - await self.set_configuration_data() - await self._settle_controller_connection() - await self.initialize() - - async def set_configuration_data(self): - """Set the device configuration data.""" - await self._set_serial_line_defaults() - await self.io.set_baudrate(19200) - - async def _set_serial_line_defaults(self) -> None: - await self.io.set_latency_timer(16) - await self.io.set_line_property(bits=8, stopbits=1, parity=0) - await self.io.set_flowctrl(0) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - async def _settle_controller_connection(self) -> None: - await asyncio.sleep(CONTROLLER_CONNECT_SETTLE_SECONDS) - - async def _purge_io_buffers(self) -> None: - try: - await self.io.usb_purge_rx_buffer() - await self.io.usb_purge_tx_buffer() - except Exception as e: - logger.debug("[vspin] Ignoring buffer purge during close/setup: %s", e) - - async def _close_connection_cleanly(self) -> None: - try: - await asyncio.sleep(0.20) - await self._purge_io_buffers() - try: - await self.io.set_dtr(False) - await self.io.set_rts(False) - except Exception as e: - logger.debug("[vspin] Ignoring control-line reset during close: %s", e) - await asyncio.sleep(0.20) - finally: - await self.io.stop() - - async def initialize(self): - for _ in range(2): - await self.io.write(b"\x00" * 20) - await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) - await self._send_command(bytes.fromhex("aaff0f0e"), read_timeout=0.08) - await asyncio.sleep(COMMAND_GAP_SECONDS) - - # Centrifuge operations - - async def open_door(self): - try: - if await self.get_door_open(): - await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) - return - except IOError: - pass - - try: - await self.unlock_door() - except IOError: - pass - - await self._send_safe(bytes.fromhex("aa022600072f"), timeout=0.30) - await self._wait_for_door(open_expected=True, timeout=4.0) - await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) - - async def close_door(self): - try: - if not await self.get_door_open(): - return - except IOError: - pass - - await self._send_safe(bytes.fromhex("aa022600052d"), timeout=0.30) - await self._wait_for_door(open_expected=False, timeout=4.0) - await asyncio.sleep(DOOR_CLOSE_SETTLE_SECONDS) - self._motion_is_prepared = False - - async def lock_door(self): - if await self.get_door_open(): - raise RuntimeError("Cannot lock door while it is open.") - if await self.get_door_locked(): - return - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._wait_for_io_state( - label="door lock", - timeout=2.5, - door_open=False, - door_locked=True, - bucket_locked=False, - settled=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - - async def unlock_door(self): - if not await self.get_door_locked(): - return - await self._send_safe(bytes.fromhex("aa022600042c"), timeout=0.20) - await asyncio.sleep(DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS) - - async def lock_bucket(self): - if await self.get_bucket_locked(): - return - await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.25) - await self._wait_for_io_state( - label="bucket lock", - timeout=1.5, - bucket_locked=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = False - - async def unlock_bucket(self): - if not await self.get_bucket_locked(): - return - await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.25) - await self._wait_for_io_state( - label="bucket unlock", - timeout=1.5, - bucket_locked=False, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True - - async def go_to_bucket1(self): - await self.go_to_position(await self._get_bucket_position(1)) - - async def go_to_bucket2(self): - await self.go_to_position(await self._get_bucket_position(2)) - - async def go_to_position(self, position: int): - position = int(position) - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if not self._motion_is_prepared: - await self._prepare_bucket_motion() - - for attempt in range(1, POSITION_MOVE_ATTEMPTS + 1): - if attempt > 1: - logger.warning( - "[vspin] Retrying position move to %d after wrong idle position", - position, - ) - self._motion_is_prepared = False - await self._prepare_bucket_motion() - - await self._motor_enable() - await self._send_safe( - bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), - timeout=0.20, - expected_len=14, - ) - await self._send_safe( - _build_vspin_position_command(position), - timeout=0.25, - expected_len=14, - ) - try: - await self._wait_for_idle( - label=f"position {position}", - timeout=25.0, - target_position=position, - tolerance=POSITION_SETTLE_TOLERANCE, - ) - break - except TimeoutError: - if attempt == POSITION_MOVE_ATTEMPTS: - raise - - await self.lock_bucket() - await self.open_door() - - @staticmethod - def g_to_rpm(g: float) -> int: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) - return rpm - - @staticmethod - def rpm_to_g(rpm: float) -> float: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - return 1.118 * 10**-5 * r * float(rpm) ** 2 - - async def spin( - self, - g: float = 500, - duration: float = 60, - acceleration: float = 0.8, - deceleration: float = 0.8, - ) -> None: - """Start a spin cycle. spin spin spin spin - - Args: - g: relative centrifugal force, also known as g-force - duration: time in seconds spent at speed (g) - acceleration: 0-1 of total acceleration - deceleration: 0-1 of total deceleration - """ - - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if g < 1 or g > 1000: - raise ValueError("G-force must be within 1-1000") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") - - await self.spin_rpm( - rpm=V11VSpinBackend.g_to_rpm(g), - duration=duration, - acceleration=acceleration, - deceleration=deceleration, - ) - - async def spin_rpm( - self, - rpm: int, - duration: float, - acceleration: float = 0.8, - deceleration: float = 0.8, - ) -> None: - """Start a spin cycle at a target RPM. - - This is a V11-specific convenience wrapper around the same command path - used by :meth:`spin`. The public PLR centrifuge API remains g-based. - """ - rpm = int(rpm) - duration = float(duration) - - if rpm < 1 or rpm > 3000: - raise ValueError("RPM must be within 1-3000.") - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") - - if await self.get_door_open(): - await self.close_door() - - self._stop_requested = False - - try: - for attempt in range(1, SPIN_START_ATTEMPTS + 1): - try: - await self._prepare_spin_motion() - await self._motor_enable() - # Sample before entering the spin profile; D4 97 must follow it immediately. - current_position = await self.get_position() - await self._send_safe( - bytes.fromhex("aa01e60500640000000000fd00803e01000c"), - timeout=0.25, - expected_len=14, - ) - - spin_command, final_position = _build_vspin_spin_command( - current_position=current_position, - rpm=rpm, - duration=duration, - acceleration=acceleration, - ) - await self._send_safe(spin_command, timeout=0.25, expected_len=14) - - await self._wait_for_speed_or_motion(rpm=rpm, final_position=final_position) - await self._hold_spin(duration) - await self._send_deceleration(deceleration) - break - except TimeoutError: - if attempt == SPIN_START_ATTEMPTS: - raise - logger.warning("[vspin] Retrying spin start after target speed was not reached") - await self._send_deceleration(deceleration) - await self._wait_for_idle(label="spin retry rundown", timeout=45.0) - self._motion_is_prepared = False - except asyncio.CancelledError: - await self._send_deceleration(deceleration) - raise - except Exception: - logger.exception("[vspin] Spin failed; attempting deceleration") - try: - await self._send_deceleration(deceleration) - except Exception: - logger.exception("[vspin] Deceleration after failed spin also failed") - raise - finally: - self._stop_requested = False - - await self._wait_for_idle(label="spin rundown", timeout=90.0) - await self._home_rotor() diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index ee8bcbe5fed..c2ba0a1dc53 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -159,7 +159,8 @@ def _load_vspin_calibrations(device_id: str) -> Optional[int]: if not os.path.exists(_vspin_bucket_calibrations_path): warnings.warn( f"No calibration found for VSpin with device id {device_id}. " - "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + f"Using the default bucket 1 offset of home + {DEFAULT_BUCKET_1_OFFSET} ticks. " + "Use `set_bucket_1_position_to_current` after setup to override it.", UserWarning, ) return None @@ -180,6 +181,128 @@ def _save_vspin_calibrations(device_id, remainder: int): FULL_ROTATION: int = 8000 +DEFAULT_BUCKET_1_OFFSET: int = 5337 +DEFAULT_BUCKET_1_REMAINDER: int = -DEFAULT_BUCKET_1_OFFSET +DEFAULT_READ_TIMEOUT: float = 0.2 +COMMAND_GAP_SECONDS: float = 0.05 +CONTROLLER_CONNECT_SETTLE_SECONDS: float = 2.0 +INITIALIZE_PACKET_GAP_SECONDS: float = 0.01 +STATUS_POLL_INTERVAL: float = 0.15 +POSITION_TOLERANCE: int = 15 +POSITION_SETTLE_TOLERANCE: int = 200 +POSITION_MOVE_ATTEMPTS: int = 2 +SPIN_START_ATTEMPTS: int = 2 +HOMING_TIMEOUT: float = 60.0 +HOMING_MIN_WAIT_SECONDS: float = 1.0 +TACH_TO_RPM: float = -14.69320388 +DOOR_OPEN_SETTLE_SECONDS: float = 2.0 +DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 +DOOR_CLOSE_SETTLE_SECONDS: float = 1.0 +PNEUMATIC_SETTLE_SECONDS: float = 0.35 + +_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x0B, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} +_IDLE_VSPIN_STATUSES = {0x09, 0x0B, 0x11, 0x89, 0x91} + +_VSPIN_MODEL_ALIASES = { + "agilent": "agilent", + "velocity11": "velocity11", + "v11": "velocity11", +} + +_VSPIN_HUMAN_READABLE_NAMES = { + "agilent": "Agilent VSpin Centrifuge", + "velocity11": "Velocity11 VSpin Centrifuge", +} + +_VSPIN_COMMANDS = { + "agilent": { + "open_door": bytes.fromhex("aa022600062e"), + "close_door": bytes.fromhex("aa022600042c"), + "lock_door": bytes.fromhex("aa0226000028"), + "unlock_door": bytes.fromhex("aa022600042c"), + "lock_bucket": bytes.fromhex("aa022600072f"), + "unlock_bucket": bytes.fromhex("aa022600062e"), + }, + "velocity11": { + "open_door": bytes.fromhex("aa022600072f"), + "close_door": bytes.fromhex("aa022600052d"), + "lock_door": bytes.fromhex("aa0226000028"), + "unlock_door": bytes.fromhex("aa022600042c"), + "lock_bucket": bytes.fromhex("aa0226000129"), + "unlock_bucket": bytes.fromhex("aa0226200048"), + }, +} + + +def _normalize_vspin_model(model: str) -> str: + normalized_model = model.lower() + if normalized_model not in _VSPIN_MODEL_ALIASES: + raise ValueError("model must be 'agilent', 'velocity11', or 'v11'") + return _VSPIN_MODEL_ALIASES[normalized_model] + + +def _with_vspin_checksum(cmd: bytes) -> bytes: + """Return ``cmd`` with the final VSpin checksum byte recomputed.""" + if len(cmd) <= 2 or cmd[0] != 0xAA: + return cmd + payload = cmd[1:-1] + return b"\xaa" + payload + bytes([sum(payload) & 0xFF]) + + +def _build_vspin_position_command(position: int) -> bytes: + position_bytes = int(position).to_bytes(4, byteorder="little") + payload = b"\x01\xd4\x97" + position_bytes + bytes.fromhex("c3f52800d71a0000") + return _with_vspin_checksum(b"\xaa" + payload + b"\x00") + + +def _build_vspin_spin_command( + current_position: int, + rpm: int, + duration: float, + acceleration: float, +) -> tuple[bytes, int]: + ticks_per_second = (int(rpm) / 60.0) * FULL_ROTATION + acceleration_ticks_per_second2 = 12903.2 * float(acceleration) + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + distance_at_speed = ticks_per_second * float(duration) + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + position_bytes = final_position.to_bytes(4, byteorder="little") + rpm_bytes = int(int(rpm) * 4473.925).to_bytes(4, byteorder="little") + acceleration_bytes = int(9.15 * 100 * float(acceleration)).to_bytes( + 4, byteorder="little" + ) + payload = b"\x01\xd4\x97" + position_bytes + rpm_bytes + acceleration_bytes + return _with_vspin_checksum(b"\xaa" + payload + b"\x00"), final_position + + +def _build_vspin_deceleration_command(deceleration: float) -> bytes: + deceleration_bytes = int(9.15 * 100 * float(deceleration)).to_bytes( + 2, byteorder="little" + ) + return _with_vspin_checksum( + bytes.fromhex("aa0194b600000000") + deceleration_bytes + b"\x00\x00\x00" + ) + + +def _normalize_vspin_home_position(home_position: int) -> int: + return int(home_position) % FULL_ROTATION + + +def _vspin_position_matches_target(position: int, target: int, tolerance: int) -> bool: + absolute_delta = abs(int(position) - int(target)) + if absolute_delta <= tolerance: + return True + + angular_delta = abs((int(position) % FULL_ROTATION) - (int(target) % FULL_ROTATION)) + angular_delta = min(angular_delta, FULL_ROTATION - angular_delta) + return angular_delta <= tolerance bucket_1_not_set_error = RuntimeError( @@ -190,24 +313,105 @@ def _save_vspin_calibrations(device_id, remainder: int): class VSpinBackend(CentrifugeBackend): - """Backend for the Agilent Centrifuge. - Note that this is not a complete implementation.""" + """Backend for Agilent and Velocity11 VSpin centrifuges. + + The Agilent and Velocity11 labels share most of the wire protocol. Known + firmware differences are isolated behind ``model`` so parsing, polling, + homing, and spin behavior stay in one implementation. + """ - def __init__(self, device_id: Optional[str] = None): + def __init__( + self, + device_id: Optional[str] = None, + model: str = "agilent", + try_runtime_attach_after_startup_failure: bool = False, + ): """ Args: - device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` + device_id: The libftdi id for the centrifuge. + Find using `python -m pylibftdi.examples.list_devices`. + model: VSpin command flavor. Use ``"agilent"`` for the existing backend + behavior and ``"velocity11"`` or ``"v11"`` for older firmware. + try_runtime_attach_after_startup_failure: Try to attach to a controller + that is already in 57600-baud runtime mode before cold startup, and + retry that attach path if the normal 19200-baud startup handshake fails. + This only applies to the Velocity11 model path. """ - self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = None + self._model = _normalize_vspin_model(model) + self.io = FTDI( + human_readable_device_name=_VSPIN_HUMAN_READABLE_NAMES[self._model], + device_id=device_id, + ) + self._bucket_1_remainder: Optional[int] = ( + None if self._model == "agilent" else DEFAULT_BUCKET_1_REMAINDER + ) + self._last_position: int = 0 + self._last_home_position: int = 0 + self._home_sensor_position: Optional[int] = None + self._motion_is_prepared = False + self._stop_requested = False + self._command_lock: Optional[asyncio.Lock] = None + self._last_command_at = 0.0 + self._try_runtime_attach_after_startup_failure = try_runtime_attach_after_startup_failure # only attempt loading calibration if device_id is not None # if it is None, we will load it after setup when we can query the device id from the io if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) + calibration = _load_vspin_calibrations(device_id) + if calibration is not None: + self._bucket_1_remainder = calibration async def setup(self): await self.io.setup() - # TODO: add functionality where if robot has been initialized before nothing needs to happen + try: + self._command_lock = asyncio.Lock() + self._motion_is_prepared = False + + if self._model == "agilent": + await self._setup_agilent() + elif self._model == "velocity11": + await self._setup_velocity11() + else: + raise ValueError(f"Unsupported VSpin model: {self._model}") + + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + elif self._bucket_1_remainder == DEFAULT_BUCKET_1_REMAINDER: + device_id = await self.io.get_serial() + calibration = _load_vspin_calibrations(device_id) + if calibration is not None: + self._bucket_1_remainder = calibration + except Exception: + await self._close_connection_cleanly() + raise + + async def _setup_velocity11(self) -> None: + attached_to_runtime = False + if self._try_runtime_attach_after_startup_failure: + attached_to_runtime = await self._try_attach_to_runtime_controller() + if attached_to_runtime: + logger.info("[vspin] Attached to runtime controller before cold startup") + + if not attached_to_runtime: + await self.configure_and_initialize() + try: + await self._startup_handshake() + await self._enable_telemetry_and_pneumatics() + except TimeoutError as e: + if ( + self._try_runtime_attach_after_startup_failure + and await self._try_attach_to_runtime_controller() + ): + logger.info("[vspin] Recovered controller after partial startup") + else: + raise TimeoutError( + "VSpin did not respond to the 19200-baud startup handshake. " + "Power-cycle or restart the VSpin controller, then try setup again." + ) from e + + await self._home_rotor() + + async def _setup_agilent(self) -> None: for _ in range(3): await self.configure_and_initialize() await self._send_command(bytes.fromhex("aa002101ff21")) @@ -238,7 +442,6 @@ async def setup(self): await self.lock_door() await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) await self._send_command(bytes.fromhex("aa0117041c")) @@ -254,7 +457,6 @@ async def setup(self): while resp == 0x89: resp = (await self._get_positions_and_tachometer()).status - # --- almost the same as go to position --- await self._send_command(bytes.fromhex("aa0117021a")) await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) await self._send_command(bytes.fromhex("aa0117041c")) @@ -262,27 +464,18 @@ async def setup(self): await self._send_command(bytes.fromhex("aa010b0c")) await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") # arbitrary - # rpm = 600, - # acceleration = 75.09289617486338 + new_position = (0).to_bytes(4, byteorder="little") await self._send_command( bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") ) - # ----------------------------------------- resp = 0x08 while resp != 0x09: resp = (await self._get_positions_and_tachometer()).status await self._send_command(bytes.fromhex("aa0117021a")) - await self.lock_door() - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - @property def bucket_1_remainder(self) -> int: if self._bucket_1_remainder is None: @@ -293,8 +486,16 @@ async def set_bucket_1_position_to_current(self) -> None: """Set the current position as bucket 1 position and save calibration.""" current_position = await self.get_position() device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position - self._bucket_1_remainder = current_position % FULL_ROTATION + home_sensor_position = ( + self._home_sensor_position + if self._home_sensor_position is not None + else await self.get_home_position() + ) + home_sensor_position = _normalize_vspin_home_position(home_sensor_position) + home_rotations = (current_position - home_sensor_position) // FULL_ROTATION + home_position = home_sensor_position + home_rotations * FULL_ROTATION + remainder = home_position - current_position + self._bucket_1_remainder = remainder _save_vspin_calibrations(device_id, remainder) async def get_bucket_1_position(self) -> int: @@ -303,22 +504,45 @@ async def get_bucket_1_position(self) -> int: The bucket 1 position must be greater than the current position, so we find the first position greater than the current position by adding full rotations if needed. """ + return await self._get_bucket_position(1) + + async def _get_bucket_position(self, bucket_num: int) -> int: + if bucket_num not in (1, 2): + raise ValueError("bucket_num must be 1 or 2") if self._bucket_1_remainder is None: raise bucket_1_not_set_error - home_position = await self.get_home_position() - bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - # first number after current position that matches bucket 1 position mod FULL_ROTATION + + home_position = self._home_sensor_position + if home_position == 0: + home_position = None + if home_position is None: + live_home_position = await self.get_home_position() + if live_home_position == 0: + await self._home_rotor() + home_position = self._home_sensor_position + if home_position == 0: + home_position = None + else: + home_position = live_home_position + + if home_position is None: + raise RuntimeError("VSpin home sensor position is unknown. Run setup or _home_rotor first.") + current_position = await self.get_position() - bucket_1_position = ( - FULL_ROTATION - * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) - + bucket_1_position_mod_full_rotation - ) - return bucket_1_position + target_position = home_position - self.bucket_1_remainder + if bucket_num == 2: + target_position += FULL_ROTATION // 2 + + while target_position <= current_position + POSITION_TOLERANCE: + target_position += FULL_ROTATION + + return target_position async def stop(self): - await self.configure_and_initialize() - await self.io.stop() + await self._close_connection_cleanly() + + def _command(self, name: str) -> bytes: + return _VSPIN_COMMANDS[self._model][name] class _StatusPositionTachometer(ctypes.LittleEndianStructure): _pack_ = 1 @@ -332,6 +556,71 @@ class _StatusPositionTachometer(ctypes.LittleEndianStructure): ("checksum", ctypes.c_uint8), ] + @staticmethod + def _make_status( + status: int, + current_position: int, + unknown1: int = 0, + tachometer: int = 0, + unknown2: int = 0, + home_position: int = 0, + checksum: int = 0, + ) -> _StatusPositionTachometer: + parsed = VSpinBackend._StatusPositionTachometer() + parsed.status = status + parsed.current_position = current_position + parsed.unknown1 = unknown1 + parsed.tachometer = tachometer + parsed.unknown2 = unknown2 + parsed.home_position = home_position + parsed.checksum = checksum + return parsed + + @staticmethod + def _find_status_packet(resp: bytes) -> Optional[_StatusPositionTachometer]: + for start in range(max(0, len(resp) - 13)): + packet = resp[start : start + 14] + if len(packet) < 14 or packet[0] not in _KNOWN_VSPIN_STATUSES: + continue + if (sum(packet[:-1]) & 0xFF) != packet[-1]: + continue + return VSpinBackend._StatusPositionTachometer.from_buffer_copy(packet) + return None + + @staticmethod + def _find_short_status(resp: bytes) -> Optional[int]: + if len(resp) == 5 and resp[0] == 0x00 and (sum(resp[:-1]) & 0xFF) == resp[-1]: + if resp[2] in _KNOWN_VSPIN_STATUSES: + return resp[2] + + for index, value in enumerate(resp): + if value not in _KNOWN_VSPIN_STATUSES: + continue + if len(resp) == 1: + return value + if index + 1 < len(resp) and resp[index + 1] == value: + return value + return None + + def _parse_position_status(self, resp: bytes) -> _StatusPositionTachometer: + full_status = self._find_status_packet(resp) + if full_status is not None: + return full_status + + short_status = self._find_short_status(resp) + if short_status is not None: + return self._make_status( + status=short_status, + current_position=self._last_position, + home_position=self._last_home_position, + ) + + return self._make_status( + status=0x19, + current_position=self._last_position, + home_position=self._last_home_position, + ) + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: """Returns 14 bytes @@ -355,18 +644,29 @@ async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - 10th to 13th byte (index 9-12) = Homing Position - Last byte (index 13) = checksum """ - resp = await self._send_command(bytes.fromhex("aa010e0f")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge") - return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + resp = await self._send_command(bytes.fromhex("aa010e0f"), expected_len=14) + status = self._parse_position_status(resp) + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + return status + + async def _get_full_positions_and_tachometer(self) -> _StatusPositionTachometer: + resp = await self._send_command( + bytes.fromhex("aa01121f32"), + read_timeout=0.40, + expected_len=14, + ) + status = self._parse_position_status(resp) + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + return status async def get_position(self) -> int: return (await self._get_positions_and_tachometer()).current_position # type: ignore async def get_tachometer(self) -> int: """current speed in rpm""" - tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM - return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + return (await self._get_positions_and_tachometer()).tachometer * TACH_TO_RPM # type: ignore async def get_home_position(self) -> int: """changes during a run, but the bucket 1 position relative to it does not""" @@ -379,9 +679,9 @@ async def _get_status(self): - 0080f0015 """ - resp = await self._send_command(bytes.fromhex("aa020e10")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge. Is the machine on?") + resp = await self._send_command(bytes.fromhex("aa020e10"), expected_len=5) + if len(resp) < 3: + raise IOError(f"Invalid status from centrifuge: {resp.hex() or '(empty)'}") return resp async def get_bucket_locked(self) -> bool: @@ -398,124 +698,787 @@ async def get_door_locked(self) -> bool: # Centrifuge communication: read_resp, send - async def _read_resp(self, timeout: float = 20) -> bytes: - """Read a response from the centrifuge. If the timeout is reached, return the data that has - been read so far.""" + async def _read_resp( + self, + timeout: float = DEFAULT_READ_TIMEOUT, + expected_len: Optional[int] = None, + quiet_time: float = 0.05, + ) -> bytes: + """Read raw binary VSpin responses. + + VSpin status replies are usually 2, 5, or 14 raw bytes and do not end in + CR. Waiting for 0x0d creates avoidable timeouts on normal status polling. + """ data = b"" - end_byte_found = False - start_time = time.time() + start_time = time.monotonic() + last_data_time: Optional[float] = None - while True: + while time.monotonic() - start_time < timeout: chunk = await self.io.read(25) if chunk: - data += chunk - end_byte_found = data[-1] == 0x0D - if len(chunk) < 25 and end_byte_found: + data += bytes(chunk) + last_data_time = time.monotonic() + if expected_len is not None and len(data) >= expected_len: break - else: - if end_byte_found or time.time() - start_time > timeout: - break - await asyncio.sleep(0.0001) + continue + + if ( + data + and expected_len is None + and last_data_time is not None + and time.monotonic() - last_data_time >= quiet_time + ): + break - logger.debug("Read %s", data.hex()) + await asyncio.sleep(0.003) + + logger.debug("[vspin] Read %s", data.hex()) return data - async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: - written = await self.io.write(bytes(cmd)) + async def _send_safe( + self, + cmd: bytes, + retries: int = 3, + timeout: float = DEFAULT_READ_TIMEOUT, + expect_response: bool = True, + expected_len: Optional[int] = None, + ) -> bytes: + for attempt in range(1, retries + 1): + resp = await self._send_command( + cmd, + read_timeout=timeout, + expected_len=expected_len, + ) + if resp or not expect_response: + return resp + + logger.debug( + "[vspin] Empty response to %s (attempt %d/%d)", + cmd.hex(), + attempt, + retries, + ) + await asyncio.sleep(0.15) + + raise TimeoutError(f"No response to VSpin command {cmd.hex()}") + + @staticmethod + def _expected_response_len(cmd: bytes) -> Optional[int]: + if cmd == bytes.fromhex("aa010e0f"): + return 14 + if cmd == bytes.fromhex("aa020e10"): + return 5 + if cmd in (bytes.fromhex("aa002101ff21"), bytes.fromhex("aa002102ff22")): + return 2 + if cmd in (bytes.fromhex("aa01132034"), bytes.fromhex("aa02132035")): + return 4 + return None + + def _get_command_lock(self) -> asyncio.Lock: + if self._command_lock is None: + self._command_lock = asyncio.Lock() + return self._command_lock + + async def _send_command( + self, + cmd: bytes, + read_timeout: float = DEFAULT_READ_TIMEOUT, + expected_len: Optional[int] = None, + ) -> bytes: + cmd = _with_vspin_checksum(bytes(cmd)) + expected_len = expected_len or self._expected_response_len(cmd) + lock = self._get_command_lock() + + logger.debug("[vspin] Sending %s", cmd.hex()) + async with lock: + command_gap = COMMAND_GAP_SECONDS - (time.monotonic() - self._last_command_at) + if command_gap > 0: + await asyncio.sleep(command_gap) + written = await self.io.write(cmd) + if written != len(cmd): + raise RuntimeError(f"Failed to write all bytes ({written}/{len(cmd)} bytes written)") + resp = await self._read_resp(timeout=read_timeout, expected_len=expected_len) + self._last_command_at = time.monotonic() + + logger.debug("[vspin] Response %s", resp.hex()) + return resp + + async def _startup_handshake(self) -> None: + await self._send_safe(bytes.fromhex("aa002101ff21"), timeout=0.60, expected_len=2) + await self._send_safe(bytes.fromhex("aa01132034"), timeout=0.60, expected_len=4) + await self._send_safe(bytes.fromhex("aa002102ff22"), timeout=0.60, expected_len=2) + await self._send_safe(bytes.fromhex("aa02132035"), timeout=0.60, expected_len=4) + + # The original software writes this and then tolerates silence for roughly + # two seconds while the controller transitions into its startup state. + await self._send_safe( + bytes.fromhex("aa002103ff23"), + timeout=0.15, + expect_response=False, + ) + await self._drain_startup_silence(2.0) + + await self._send_safe(bytes.fromhex("aaff1a142d"), timeout=0.12, expect_response=False) + await self._send_safe( + bytes.fromhex("aa010e0f"), + timeout=0.30, + expected_len=None, + expect_response=False, + ) + await self._send_safe( + bytes.fromhex("aa020e10"), + timeout=0.30, + expected_len=5, + expect_response=False, + ) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + async def _drain_startup_silence(self, seconds: float) -> None: + end = time.monotonic() + seconds + while time.monotonic() < end: + await self._read_resp(timeout=0.08, expected_len=None, quiet_time=0.01) + await asyncio.sleep(0.03) + + @staticmethod + def _is_runtime_attach_status(status: _StatusPositionTachometer) -> bool: + return int(status.current_position) != 0 or int(status.home_position) != 0 + + async def _try_attach_to_runtime_controller(self) -> bool: + """Attach to a VSpin controller that is already in 57600-baud runtime mode.""" + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + await self._purge_io_buffers() + await self._drain_startup_silence(0.25) + + for _ in range(2): + for status_command in ( + bytes.fromhex("aa010e0f"), + bytes.fromhex("aa01121f32"), + ): + resp = await self._send_command( + status_command, + read_timeout=0.40, + expected_len=14, + ) + status = self._find_status_packet(resp) + if status is not None: + if not self._is_runtime_attach_status(status): + logger.debug( + "[vspin] Ignoring blank runtime attach status " + "(status=0x%02x, position=%d, home=%d)", + status.status, + status.current_position, + status.home_position, + ) + continue + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + logger.info( + "[vspin] Attached to runtime controller " + "(status=0x%02x, position=%d, home=%d)", + status.status, + status.current_position, + status.home_position, + ) + return True + await asyncio.sleep(0.20) + + return False + + async def _enable_telemetry_and_pneumatics(self) -> None: + await self._send_safe(bytes.fromhex("aa01121f32"), timeout=0.35, expected_len=14) + + for _ in range(8): + await self._send_safe(bytes.fromhex("aa0220ff0f30"), timeout=0.12) + + for cmd in ( + bytes.fromhex("aa0220df0f10"), + bytes.fromhex("aa0220df0e0f"), + bytes.fromhex("aa0220df0c0d"), + bytes.fromhex("aa0220df0809"), + ): + await self._send_safe(cmd, timeout=0.12) + + for _ in range(4): + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.12) + + await self._send_safe(bytes.fromhex("aa02120317"), timeout=0.12) + + for _ in range(5): + await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.15) + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) + + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) + await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.15) + await self._poll_io_status(0.35) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) + await self._poll_io_status(0.35) + self._motion_is_prepared = True + + async def _poll_io_status(self, seconds: float) -> None: + end = time.monotonic() + seconds + while time.monotonic() < end: + await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.10, expected_len=5) + await asyncio.sleep(0.12) + + async def _motor_enable(self) -> None: + await self._send_safe(bytes.fromhex("aa0117021a"), timeout=0.30, expected_len=14) + await self._send_safe( + bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), + timeout=0.30, + expected_len=14, + ) + await self._send_safe(bytes.fromhex("aa0117041c"), timeout=0.30, expected_len=14) + await self._send_safe(bytes.fromhex("aa01170119"), timeout=0.30, expected_len=14) + await self._send_safe(bytes.fromhex("aa010b0c"), timeout=0.30, expected_len=14) + + async def _home_rotor(self) -> None: + pre_home_status = await self._get_positions_and_tachometer() + homing_started_at = time.monotonic() + await self._motor_enable() + await self._send_safe(bytes.fromhex("aa010001"), timeout=0.30, expected_len=14) + await self._send_safe( + bytes.fromhex("aa01e605006400000000003200e80301006e"), + timeout=0.30, + expected_len=14, + ) + await self._send_safe( + bytes.fromhex("aa0194b61283000012010000f3"), + timeout=0.30, + expected_len=14, + ) + await self._send_safe(bytes.fromhex("aa01192842"), timeout=0.30, expected_len=14) + + status = await self._wait_for_idle( + label="homing", + timeout=HOMING_TIMEOUT, + min_wait=HOMING_MIN_WAIT_SECONDS, + require_activity_from=pre_home_status, + activity_tolerance=POSITION_SETTLE_TOLERANCE, + ) + + if not self._has_fresh_home_reference( + status=status, + previous_home_position=int(pre_home_status.home_position), + ): + remaining_timeout = max(0.0, HOMING_TIMEOUT - (time.monotonic() - homing_started_at)) + logger.debug( + "[vspin] Waiting for fresh homing reference " + "(previous home=%d, current home=%d)", + pre_home_status.home_position, + status.home_position, + ) + status = await self._wait_for_homed_status( + pre_home_status=pre_home_status, + timeout=remaining_timeout, + ) + + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + self._home_sensor_position = _normalize_vspin_home_position(status.home_position) + + @staticmethod + def _has_fresh_home_reference( + status: _StatusPositionTachometer, + previous_home_position: int, + ) -> bool: + if int(status.home_position) == 0: + return False + if int(previous_home_position) == 0: + return True + return int(status.home_position) != int(previous_home_position) + + async def _wait_for_homed_status( + self, + pre_home_status: _StatusPositionTachometer, + timeout: float, + ) -> _StatusPositionTachometer: + end = time.monotonic() + timeout + previous_home_position = int(pre_home_status.home_position) + last_status: Optional[VSpinBackend._StatusPositionTachometer] = None + + while time.monotonic() < end: + status = await self._get_full_positions_and_tachometer() + last_status = status + is_idle_status = status.status in _IDLE_VSPIN_STATUSES + is_stopped = abs(status.tachometer) <= 2 + if ( + is_idle_status + and is_stopped + and self._has_fresh_home_reference(status, previous_home_position) + ): + return status + await asyncio.sleep(STATUS_POLL_INTERVAL) + + if last_status is None: + last_status = self._make_status( + 0x19, + self._last_position, + home_position=self._last_home_position, + ) + raise TimeoutError( + "VSpin homing did not report a fresh home position within " + f"{timeout:.1f}s (previous home={previous_home_position}, " + f"last status=0x{last_status.status:02x}, " + f"position={last_status.current_position}, " + f"tachometer={last_status.tachometer}, " + f"home={last_status.home_position})" + ) + + async def _wait_for_full_status( + self, + timeout: float, + allow_zero_home_fallback: bool = False, + ) -> _StatusPositionTachometer: + end = time.monotonic() + timeout + last_raw = b"" + last_full_status: Optional[VSpinBackend._StatusPositionTachometer] = None + while time.monotonic() < end: + resp = await self._send_command( + bytes.fromhex("aa010e0f"), + read_timeout=0.40, + expected_len=14, + ) + last_raw = resp + status = self._find_status_packet(resp) + if status is not None and status.home_position != 0: + self._last_position = int(status.current_position) + self._last_home_position = int(status.home_position) + return status + if status is not None: + last_full_status = status + await asyncio.sleep(0.10) + + if allow_zero_home_fallback and last_full_status is not None: + self._last_position = int(last_full_status.current_position) + self._last_home_position = int(last_full_status.home_position) + return last_full_status + + raise TimeoutError( + "VSpin homing reached idle, but no full 14-byte status packet with a " + f"home position was received. Last raw response: {last_raw.hex() or '(empty)'}" + ) + + async def _wait_for_idle( + self, + label: str, + timeout: float, + target_position: Optional[int] = None, + tolerance: int = POSITION_TOLERANCE, + min_wait: float = 0.0, + require_activity_from: Optional[_StatusPositionTachometer] = None, + activity_tolerance: int = POSITION_TOLERANCE, + ) -> _StatusPositionTachometer: + start = time.monotonic() + last_status = self._make_status( + 0x19, + self._last_position, + home_position=self._last_home_position, + ) + observed_activity = require_activity_from is None + + while time.monotonic() - start <= timeout: + status = await self._get_positions_and_tachometer() + last_status = status + is_idle_status = status.status in _IDLE_VSPIN_STATUSES + is_stopped = abs(status.tachometer) <= 2 + should_probe_full_status = is_idle_status and is_stopped and ( + (require_activity_from is not None and not observed_activity) + or ( + target_position is not None + and not _vspin_position_matches_target( + position=int(status.current_position), + target=int(target_position), + tolerance=tolerance, + ) + ) + ) + if should_probe_full_status: + full_status = await self._get_full_positions_and_tachometer() + if full_status.status in _IDLE_VSPIN_STATUSES and abs(full_status.tachometer) <= 2: + status = full_status + last_status = status + is_idle_status = True + is_stopped = True + + if require_activity_from is not None and not observed_activity: + observed_activity = ( + not is_idle_status + or not is_stopped + or not _vspin_position_matches_target( + position=int(status.current_position), + target=int(require_activity_from.current_position), + tolerance=activity_tolerance, + ) + or ( + int(status.home_position) != 0 + and int(status.home_position) != int(require_activity_from.home_position) + ) + ) + is_at_target = ( + target_position is None + or _vspin_position_matches_target( + position=int(status.current_position), + target=int(target_position), + tolerance=tolerance, + ) + ) + + if ( + is_idle_status + and is_stopped + and is_at_target + and observed_activity + and time.monotonic() - start >= min_wait + ): + return status + + await asyncio.sleep(STATUS_POLL_INTERVAL) + raise TimeoutError( + f"VSpin {label} did not become idle within {timeout:.1f}s " + f"(status=0x{last_status.status:02x}, position={last_status.current_position}, " + f"tachometer={last_status.tachometer}, home={last_status.home_position})" + ) + + async def _prepare_bucket_motion(self) -> None: + if self._model == "agilent": + await self._send_safe(self._command("lock_door"), timeout=0.20) + self._motion_is_prepared = True + return + + await self._send_safe(self._command("lock_bucket"), timeout=0.20) + await self._wait_for_io_state( + label="bucket motion lock", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) + await self._wait_for_io_state( + label="bucket motion ready", + timeout=2.5, + door_open=False, + door_locked=True, + bucket_locked=False, + settled=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + self._motion_is_prepared = True + + async def _prepare_spin_motion(self) -> None: + if self._model == "agilent": + await self._send_safe(self._command("lock_door"), timeout=0.20) + self._motion_is_prepared = True + return - if written != len(cmd): - raise RuntimeError("Failed to write all bytes") - return await self._read_resp(timeout=read_timeout) + await self._send_safe(self._command("lock_bucket"), timeout=0.20) + await self._wait_for_io_state( + label="spin bucket lock", + timeout=1.5, + door_open=False, + door_locked=True, + bucket_locked=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) + await self._wait_for_io_state( + label="spin ready", + timeout=2.5, + door_open=False, + door_locked=True, + bucket_locked=False, + settled=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + self._motion_is_prepared = True + + async def _wait_for_io_state( + self, + label: str, + timeout: float, + door_open: Optional[bool] = None, + door_locked: Optional[bool] = None, + bucket_locked: Optional[bool] = None, + settled: bool = False, + ) -> None: + end = time.monotonic() + timeout + last_status = b"" + while time.monotonic() < end: + try: + last_status = await self._get_status() + except IOError: + await asyncio.sleep(0.05) + continue + + if len(last_status) >= 3: + value = last_status[2] + matches = [] + if door_open is not None: + matches.append((value & 0b0010 != 0) is door_open) + if door_locked is not None: + matches.append((value & 0b0100 == 0) is door_locked) + if bucket_locked is not None: + matches.append((value & 0b0001 != 0) is bucket_locked) + if settled: + matches.append(last_status[1] in (0x00, 0x10)) + if all(matches): + return + + await asyncio.sleep(0.05) + + raise TimeoutError( + f"VSpin {label} IO state was not reached within {timeout:.1f}s " + f"(last status={last_status.hex() or '(empty)'})" + ) + + async def _send_deceleration(self, deceleration: float) -> None: + await self._send_safe( + bytes.fromhex("aa01e60500640000000000fd00803e01000c"), + timeout=0.25, + ) + await self._send_safe(_build_vspin_deceleration_command(deceleration), timeout=0.25) + + async def _wait_for_door(self, open_expected: bool, timeout: float) -> None: + end = time.monotonic() + timeout + while time.monotonic() < end: + try: + if await self.get_door_open() is open_expected: + return + except IOError: + pass + await asyncio.sleep(0.12) + + expected = "open" if open_expected else "closed" + raise TimeoutError(f"VSpin door did not report {expected} within {timeout:.1f}s") + + async def _wait_for_speed_or_motion( + self, + rpm: int, + final_position: int, + timeout: float = 25.0, + ) -> None: + deadline = time.monotonic() + timeout + last_status = self._make_status( + 0x19, + self._last_position, + home_position=self._last_home_position, + ) + last_live_rpm = 0.0 + while time.monotonic() < deadline and not self._stop_requested: + status = await self._get_positions_and_tachometer() + last_status = status + last_live_rpm = abs(status.tachometer * TACH_TO_RPM) + if last_live_rpm >= rpm * 0.92: + return + await asyncio.sleep(0.25) + + raise TimeoutError( + f"VSpin did not reach target speed {rpm} rpm within {timeout:.1f}s " + f"(last rpm={last_live_rpm:.1f}, position={last_status.current_position}, " + f"home={last_status.home_position})" + ) + + async def _hold_spin(self, duration: float) -> None: + started = time.monotonic() + while not self._stop_requested and time.monotonic() - started < duration: + await self._get_positions_and_tachometer() + await asyncio.sleep(min(1.0, duration)) async def configure_and_initialize(self): await self.set_configuration_data() + await self._settle_controller_connection() await self.initialize() async def set_configuration_data(self): """Set the device configuration data.""" + await self._set_serial_line_defaults() + await self.io.set_baudrate(19200) + + async def _set_serial_line_defaults(self) -> None: await self.io.set_latency_timer(16) await self.io.set_line_property(bits=8, stopbits=1, parity=0) await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + async def _settle_controller_connection(self) -> None: + await asyncio.sleep(CONTROLLER_CONNECT_SETTLE_SECONDS) + + async def _purge_io_buffers(self) -> None: + try: + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + except Exception as e: + logger.debug("[vspin] Ignoring buffer purge during close/setup: %s", e) + + async def _close_connection_cleanly(self) -> None: + try: + await asyncio.sleep(0.20) + await self._purge_io_buffers() + try: + await self.io.set_dtr(False) + await self.io.set_rts(False) + except Exception as e: + logger.debug("[vspin] Ignoring control-line reset during close: %s", e) + await asyncio.sleep(0.20) + finally: + await self.io.stop() async def initialize(self): - await self.io.write(b"\x00" * 20) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await self._send_command(bytes.fromhex("aaff0f0e")) + for _ in range(2): + await self.io.write(b"\x00" * 20) + await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) + await self._send_command(bytes.fromhex("aaff0f0e"), read_timeout=0.08) + await asyncio.sleep(COMMAND_GAP_SECONDS) # Centrifuge operations async def open_door(self): - if await self.get_door_open(): - return - # used to be: aa022600072f - await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door - - # we can't tell when the door is fully open, so we just wait a bit - await asyncio.sleep(4) + try: + if await self.get_door_open(): + await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) + return + except IOError: + pass + + try: + await self.unlock_door() + except IOError: + pass + + await self._send_safe(self._command("open_door"), timeout=0.30) + await self._wait_for_door(open_expected=True, timeout=4.0) + await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) async def close_door(self): - if not (await self.get_door_open()): - return - # used to be: aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door - # we can't tell when the door is fully closed, so we just wait a bit - await asyncio.sleep(2) + try: + if not await self.get_door_open(): + return + except IOError: + pass + + await self._send_safe(self._command("close_door"), timeout=0.30) + await self._wait_for_door(open_expected=False, timeout=4.0) + await asyncio.sleep(DOOR_CLOSE_SETTLE_SECONDS) + self._motion_is_prepared = False async def lock_door(self): if await self.get_door_open(): raise RuntimeError("Cannot lock door while it is open.") if await self.get_door_locked(): return - # used to be aa0226000129 - await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_safe(self._command("lock_door"), timeout=0.20) + if self._model == "agilent": + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + return + await self._wait_for_io_state( + label="door lock", + timeout=2.5, + door_open=False, + door_locked=True, + bucket_locked=False, + settled=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) async def unlock_door(self): if not await self.get_door_locked(): return - # used to be aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as close door + await self._send_safe(self._command("unlock_door"), timeout=0.20) + await asyncio.sleep(DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS) async def lock_bucket(self): if await self.get_bucket_locked(): return - await self._send_command(bytes.fromhex("aa022600072f")) + await self._send_safe(self._command("lock_bucket"), timeout=0.25) + if self._model == "agilent": + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + self._motion_is_prepared = False + return + await self._wait_for_io_state( + label="bucket lock", + timeout=1.5, + bucket_locked=True, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + self._motion_is_prepared = False async def unlock_bucket(self): if not await self.get_bucket_locked(): return - await self._send_command(bytes.fromhex("aa022600062e")) # same as open door + await self._send_safe(self._command("unlock_bucket"), timeout=0.25) + if self._model == "agilent": + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + self._motion_is_prepared = True + return + await self._wait_for_io_state( + label="bucket unlock", + timeout=1.5, + bucket_locked=False, + ) + await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + self._motion_is_prepared = True async def go_to_bucket1(self): - await self.go_to_position(await self.get_bucket_1_position()) + await self.go_to_position(await self._get_bucket_position(1)) async def go_to_bucket2(self): - await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + await self.go_to_position(await self._get_bucket_position(2)) async def go_to_position(self, position: int): - await self.close_door() - await self.lock_door() - - position_bytes = position.to_bytes(4, byteorder="little") - byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") - sum_byte = (sum(byte_string) - 0xAA) & 0xFF - byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(byte_string) - - # await self._send_command(bytes.fromhex("aa0117021a")) - while ( - abs(await self.get_position() - position) > 10 - ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) - await asyncio.sleep(0.1) + position = int(position) + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if not self._motion_is_prepared: + await self._prepare_bucket_motion() + + for attempt in range(1, POSITION_MOVE_ATTEMPTS + 1): + if attempt > 1: + logger.warning( + "[vspin] Retrying position move to %d after wrong idle position", + position, + ) + self._motion_is_prepared = False + await self._prepare_bucket_motion() + + await self._motor_enable() + await self._send_safe( + bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), + timeout=0.20, + expected_len=14, + ) + await self._send_safe( + _build_vspin_position_command(position), + timeout=0.25, + expected_len=14, + ) + try: + await self._wait_for_idle( + label=f"position {position}", + timeout=25.0, + target_position=position, + tolerance=POSITION_SETTLE_TOLERANCE, + ) + break + except TimeoutError: + if attempt == POSITION_MOVE_ATTEMPTS: + raise + + await self.lock_bucket() await self.open_door() @staticmethod @@ -525,6 +1488,12 @@ def g_to_rpm(g: float) -> int: rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) return rpm + @staticmethod + def rpm_to_g(rpm: float) -> float: + # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula + r = 10 + return 1.118 * 10**-5 * r * float(rpm) ** 2 + async def spin( self, g: float = 500, @@ -550,133 +1519,107 @@ async def spin( if duration < 1: raise ValueError("Spin time must be at least 1 second") - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if await self.get_bucket_locked(): - await self.unlock_bucket() - - # 1 - compute the final position - rpm = VSpinBackend.g_to_rpm(g) - - # compute the distance traveled during the acceleration period - # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max - # 12903.2 ticks/s^2 is 100% acceleration - acceleration_ticks_per_second2 = 12903.2 * acceleration - rounds_per_second = rpm / 60 - ticks_per_second = rounds_per_second * 8000 - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - - # compute the distance traveled at speed - distance_at_speed = ticks_per_second * duration - - current_position = await self.get_position() - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - # this is almost 3 hours of spinning at 3000 rpm (max speed), - # so we assume nobody will ever hit this. - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - # 2 - send "go to position" command with computed final position and rpm - position_b = final_position.to_bytes(4, byteorder="little") - rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") - acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") - - byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b - checksum = (sum(byte_string) - 0xAA) & 0xFF - byte_string += checksum.to_bytes(1, byteorder="little") - - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + await self.spin_rpm( + rpm=VSpinBackend.g_to_rpm(g), + duration=duration, + acceleration=acceleration, + deceleration=deceleration, + ) - await self._send_command(byte_string) + async def spin_rpm( + self, + rpm: int, + duration: float, + acceleration: float = 0.8, + deceleration: float = 0.8, + ) -> None: + """Start a spin cycle at a target RPM. - # 3 - wait for acceleration to the set rpm - # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) - while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: - await asyncio.sleep(0.1) + This is a convenience wrapper around the same command path used by + :meth:`spin`. The public PLR centrifuge API remains g-based. + """ + rpm = int(rpm) + duration = float(duration) - # 4 - once the speed is reached, compute the position at which to start deceleration - # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. - # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. - # this is what the vendor software does too. - # if we are already past that position, we skip this part. - if await self.get_position() < final_position: - decel_start_position = await self.get_position() + distance_at_speed + if rpm < 1 or rpm > 3000: + raise ValueError("RPM must be within 1-3000.") + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") - # then wait until we reach that position - while await self.get_position() < decel_start_position: - await asyncio.sleep(0.1) + if await self.get_door_open(): + await self.close_door() - # 5 - send deceleration command - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - # aa0194b600000000dc02000029: decel at 80 - # aa0194b6000000000a03000058: decel at 85 - # aa0194b61283000012010000f3: used in setup (30%) - decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") - decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") - decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._send_command(decel_command) - - await asyncio.sleep(2) - - # 6 - reset position back to 0ish - # this part is aneeded because otherwise calling go_to_position will not work after - async def _reset_to_zero(): - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again - - await _reset_to_zero() - - # 7 - wait for home position to change - # go_to_bucket{1,2} does not work until the home position changes - start = await self.get_home_position() - num_tries = 0 - while await self.get_home_position() == start: - await asyncio.sleep(0.1) - num_tries += 1 - if num_tries % 25 == 0: - await _reset_to_zero() - if num_tries > 100: - raise RuntimeError("Home position did not change after spin.") + self._stop_requested = False + + try: + for attempt in range(1, SPIN_START_ATTEMPTS + 1): + try: + await self._prepare_spin_motion() + await self._motor_enable() + # Sample before entering the spin profile; D4 97 must follow it immediately. + current_position = await self.get_position() + await self._send_safe( + bytes.fromhex("aa01e60500640000000000fd00803e01000c"), + timeout=0.25, + expected_len=14, + ) + + spin_command, final_position = _build_vspin_spin_command( + current_position=current_position, + rpm=rpm, + duration=duration, + acceleration=acceleration, + ) + await self._send_safe(spin_command, timeout=0.25, expected_len=14) + + await self._wait_for_speed_or_motion(rpm=rpm, final_position=final_position) + await self._hold_spin(duration) + await self._send_deceleration(deceleration) + break + except TimeoutError: + if attempt == SPIN_START_ATTEMPTS: + raise + logger.warning("[vspin] Retrying spin start after target speed was not reached") + await self._send_deceleration(deceleration) + await self._wait_for_idle(label="spin retry rundown", timeout=45.0) + self._motion_is_prepared = False + except asyncio.CancelledError: + await self._send_deceleration(deceleration) + raise + except Exception: + logger.exception("[vspin] Spin failed; attempting deceleration") + try: + await self._send_deceleration(deceleration) + except Exception: + logger.exception("[vspin] Deceleration after failed spin also failed") + raise + finally: + self._stop_requested = False + + await self._wait_for_idle(label="spin rundown", timeout=90.0) + await self._home_rotor() def create_vspin_backend( device_id: Optional[str] = None, variant: str = "agilent", + try_runtime_attach_after_startup_failure: bool = False, ) -> CentrifugeBackend: """Create a VSpin backend for the selected centrifuge generation. - ``agilent`` keeps the existing PyLabRobot command path for newer - Agilent-branded VSpin centrifuges. ``velocity11`` selects the legacy - Velocity11 path derived from old hardware traces. The command sets should - not be treated as interchangeable until both generations are hardware-tested. + ``variant`` accepts ``"agilent"``, ``"velocity11"``, or ``"v11"``. All + variants use :class:`VSpinBackend`; only firmware-specific command bytes are + selected differently. """ - normalized_variant = variant.lower() - if normalized_variant == "agilent": - return VSpinBackend(device_id=device_id) - if normalized_variant == "velocity11": - from .v11_vspin_backend import V11VSpinBackend - - return V11VSpinBackend(device_id=device_id) - raise ValueError("variant must be 'agilent', or 'velocity11'") + return VSpinBackend( + device_id=device_id, + model=variant, + try_runtime_attach_after_startup_failure=try_runtime_attach_after_startup_failure, + ) # Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 7f6f4de606d..5549c0fa328 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -1,18 +1,18 @@ import unittest from unittest import mock -from pylabrobot.centrifuge.v11_vspin_backend import ( +from pylabrobot.centrifuge.vspin_backend import ( DEFAULT_BUCKET_1_REMAINDER, HOMING_TIMEOUT, POSITION_SETTLE_TOLERANCE, POSITION_TOLERANCE, - V11VSpinBackend, + VSpinBackend, _IDLE_VSPIN_STATUSES, _build_vspin_deceleration_command, _normalize_vspin_home_position, _vspin_position_matches_target, + create_vspin_backend, ) -from pylabrobot.centrifuge.vspin_backend import VSpinBackend, create_vspin_backend class _FakeIO: @@ -53,8 +53,14 @@ async def set_baudrate(self, baudrate: int): self.calls.append(("set_baudrate", baudrate)) -def _make_backend(io: _FakeIO) -> V11VSpinBackend: - backend = object.__new__(V11VSpinBackend) +def _make_raw_backend(model: str = "velocity11") -> VSpinBackend: + backend = object.__new__(VSpinBackend) + backend._model = model + return backend + + +def _make_backend(io: _FakeIO) -> VSpinBackend: + backend = _make_raw_backend() backend.io = io backend._command_lock = None backend._last_position = 0 @@ -84,7 +90,7 @@ def _status_packet( return packet + bytes([sum(packet) & 0xFF]) -class V11VSpinBackendTests(unittest.IsolatedAsyncioTestCase): +class VSpinBackendTests(unittest.IsolatedAsyncioTestCase): async def test_read_resp_returns_expected_binary_packet_without_cr(self): backend = _make_backend(_FakeIO([b"\x00\x30", b"\x08\x30\x68"])) @@ -104,7 +110,7 @@ async def test_send_command_repairs_checksum_and_uses_expected_length(self): def test_find_status_packet_scans_noise_and_validates_checksum(self): packet = _status_packet() - parsed = V11VSpinBackend._find_status_packet(b"\x00\xff" + packet + b"\x00") + parsed = VSpinBackend._find_status_packet(b"\x00\xff" + packet + b"\x00") assert parsed is not None self.assertEqual(parsed.status, 0x11) @@ -116,10 +122,10 @@ def test_find_status_packet_rejects_bad_checksum(self): packet = bytearray(_status_packet()) packet[-1] ^= 0xFF - self.assertIsNone(V11VSpinBackend._find_status_packet(bytes(packet))) + self.assertIsNone(VSpinBackend._find_status_packet(bytes(packet))) def test_find_short_status_from_io_packet(self): - self.assertEqual(V11VSpinBackend._find_short_status(bytes.fromhex("0030083068")), 0x08) + self.assertEqual(VSpinBackend._find_short_status(bytes.fromhex("0030083068")), 0x08) def test_status_0x89_is_idle_when_stopped_at_target(self): self.assertIn(0x89, _IDLE_VSPIN_STATUSES) @@ -231,17 +237,17 @@ def test_deceleration_command_uses_observed_checksum(self): ) def test_rpm_to_g_roundtrips_1500_rpm(self): - g = V11VSpinBackend.rpm_to_g(1500) + g = VSpinBackend.rpm_to_g(1500) - self.assertEqual(V11VSpinBackend.g_to_rpm(g), 1500) + self.assertEqual(VSpinBackend.g_to_rpm(g), 1500) async def test_spin_uses_rpm_command_path(self): - backend = object.__new__(V11VSpinBackend) + backend = _make_raw_backend() backend.spin_rpm = mock.AsyncMock() - await V11VSpinBackend.spin( + await VSpinBackend.spin( backend, - g=V11VSpinBackend.rpm_to_g(1500), + g=VSpinBackend.rpm_to_g(1500), duration=10, acceleration=0.7, deceleration=0.6, @@ -255,13 +261,13 @@ async def test_spin_uses_rpm_command_path(self): ) async def test_spin_rpm_validates_target_rpm(self): - backend = object.__new__(V11VSpinBackend) + backend = _make_raw_backend() with self.assertRaises(ValueError): - await V11VSpinBackend.spin_rpm(backend, rpm=0, duration=10) + await VSpinBackend.spin_rpm(backend, rpm=0, duration=10) async def test_setup_uses_cold_startup_without_runtime_probe_by_default(self): - backend = object.__new__(V11VSpinBackend) + backend = _make_raw_backend() backend.io = mock.Mock() backend.io.setup = mock.AsyncMock() backend.io.stop = mock.AsyncMock() @@ -291,13 +297,13 @@ async def home_rotor(): backend._enable_telemetry_and_pneumatics = enable_telemetry_and_pneumatics backend._home_rotor = home_rotor - await V11VSpinBackend.setup(backend) + await VSpinBackend.setup(backend) self.assertEqual(events, ["cold-start", "startup", "telemetry", "home"]) backend._try_attach_to_runtime_controller.assert_not_awaited() async def test_setup_closes_io_with_clear_error_when_controller_never_responds(self): - backend = object.__new__(V11VSpinBackend) + backend = _make_raw_backend() backend.io = mock.Mock() backend.io.setup = mock.AsyncMock() backend.io.stop = mock.AsyncMock() @@ -315,13 +321,13 @@ async def test_setup_closes_io_with_clear_error_when_controller_never_responds(s backend._home_rotor = mock.AsyncMock() with self.assertRaisesRegex(TimeoutError, "Power-cycle or restart"): - await V11VSpinBackend.setup(backend) + await VSpinBackend.setup(backend) backend._try_attach_to_runtime_controller.assert_not_awaited() backend.io.stop.assert_awaited_once() async def test_setup_can_optionally_recover_with_runtime_attach_after_startup_failure(self): - backend = object.__new__(V11VSpinBackend) + backend = _make_raw_backend() backend.io = mock.Mock() backend.io.setup = mock.AsyncMock() backend.io.stop = mock.AsyncMock() @@ -350,12 +356,12 @@ async def home_rotor(): backend._try_attach_to_runtime_controller = try_attach backend._home_rotor = home_rotor - await V11VSpinBackend.setup(backend) + await VSpinBackend.setup(backend) self.assertEqual(events, ["attach", "cold-start", "startup", "attach", "home"]) async def test_setup_tries_runtime_attach_before_cold_start_when_enabled(self): - backend = object.__new__(V11VSpinBackend) + backend = _make_raw_backend() backend.io = mock.Mock() backend.io.setup = mock.AsyncMock() backend.io.get_serial = mock.AsyncMock(return_value="TEST") @@ -369,7 +375,7 @@ async def test_setup_tries_runtime_attach_before_cold_start_when_enabled(self): backend._enable_telemetry_and_pneumatics = mock.AsyncMock() backend._home_rotor = mock.AsyncMock() - await V11VSpinBackend.setup(backend) + await VSpinBackend.setup(backend) backend._try_attach_to_runtime_controller.assert_awaited_once() backend.configure_and_initialize.assert_not_awaited() @@ -532,7 +538,12 @@ async def test_speed_wait_accepts_measured_target_rpm(self): tachometer = int(-(1000 * 0.95) / 14.69320388) backend = _make_backend( _FakeIO([ - _status_packet(status=0x08, current_position=1000, tachometer=tachometer, home_position=100), + _status_packet( + status=0x08, + current_position=1000, + tachometer=tachometer, + home_position=100, + ), ]) ) @@ -566,7 +577,7 @@ async def sleep(seconds: float) -> None: backend._send_safe = send_safe backend._wait_for_door = wait_for_door - with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.asyncio.sleep", sleep): + with mock.patch("pylabrobot.centrifuge.vspin_backend.asyncio.sleep", sleep): await backend.open_door() self.assertEqual( @@ -601,7 +612,7 @@ async def sleep(seconds: float) -> None: backend.get_door_locked = get_door_locked backend._send_safe = send_safe - with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.asyncio.sleep", sleep): + with mock.patch("pylabrobot.centrifuge.vspin_backend.asyncio.sleep", sleep): await backend.unlock_door() self.assertEqual( @@ -661,7 +672,7 @@ async def open_door() -> None: backend.lock_bucket = lock_bucket backend.open_door = open_door - with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.logger.warning"): + with mock.patch("pylabrobot.centrifuge.vspin_backend.logger.warning"): await backend.go_to_position(11842) self.assertEqual( @@ -836,7 +847,7 @@ async def wait_for_idle(**kwargs) -> None: backend._wait_for_idle = wait_for_idle backend._home_rotor = mock.AsyncMock() - with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.logger.warning"): + with mock.patch("pylabrobot.centrifuge.vspin_backend.logger.warning"): await backend.spin_rpm(rpm=1500, duration=10) self.assertEqual( @@ -887,9 +898,11 @@ def test_factory_defaults_to_agilent_backend(self): backend = create_vspin_backend() self.assertIsInstance(backend, VSpinBackend) + self.assertEqual(backend._model, "agilent") - def test_factory_can_select_legacy_v11_backend(self): - with mock.patch("pylabrobot.centrifuge.v11_vspin_backend.FTDI"): + def test_factory_can_select_legacy_v11_model(self): + with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): backend = create_vspin_backend(variant="v11") - self.assertIsInstance(backend, V11VSpinBackend) + self.assertIsInstance(backend, VSpinBackend) + self.assertEqual(backend._model, "velocity11") From b0bcb88255b1ace212132e0e929a9bee46ddb57c Mon Sep 17 00:00:00 2001 From: lucas vogel Date: Thu, 2 Jul 2026 00:41:40 +0200 Subject: [PATCH 19/22] more codex... --- README.md | 10 +- .../centrifuge/agilent_vspin.ipynb | 39 +- pylabrobot/centrifuge/__init__.py | 2 +- pylabrobot/centrifuge/vspin_backend.py | 1347 +++-------------- pylabrobot/centrifuge/vspin_backend_tests.py | 881 +---------- 5 files changed, 277 insertions(+), 2002 deletions(-) diff --git a/README.md b/README.md index 38b596013a5..f5a170c5f78 100644 --- a/README.md +++ b/README.md @@ -83,21 +83,23 @@ data = await pr.read_luminescence() For Cytation5, use the `Cytation5` backend. -### Centrifuges ([docs](https://docs.pylabrobot.org/0.2.1/user_guide/01_material-handling/centrifuge/_centrifuge.html)) +### Centrifuges ([docs](https://docs.pylabrobot.org/user_guide/01_material-handling/centrifuge/_centrifuge.html)) Centrifugation at 800g for 60 seconds with an Agilent VSpin: ```python -from pylabrobot.centrifuge import Centrifuge, create_vspin_backend +from pylabrobot.centrifuge import Centrifuge, VSpinBackend -vspin_backend = create_vspin_backend(device_id="YOUR_FTDI_ID_HERE", variant="agilent") +vspin_backend = VSpinBackend(device_id="YOUR_FTDI_ID_HERE") cf = Centrifuge(name="centrifuge", backend=vspin_backend, size_x=1, size_y=1, size_z=1) await cf.setup() await cf.spin(g=800, duration=60) ``` -Use `variant="velocity11"` for legacy Velocity11 VSpin centrifuges. +Use `VSpinBackend(command_set="old_firmware")` for VSpins that need the older +pneumatic command bytes. Some Velocity11-labeled units work with the default +command set. For a HighRes Biosolutions MicroSpin, use the MicroSpin factory: diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb index c93a848d54f..58575b47625 100644 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb +++ b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb @@ -7,9 +7,9 @@ "source": [ "# Agilent VSpin\n", "\n", - "The Agilent-branded VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. It remains the default backend for newer Agilent systems.\n", + "The VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class. The default command set works for known Agilent units and some Velocity11-labeled units.\n", "\n", - "Legacy Velocity11 units use the same {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class through the {func}`~pylabrobot.centrifuge.vspin_backend.create_vspin_backend` helper with `variant=\\\"velocity11\\\"`." + "Use `VSpinBackend(command_set=\\\"old_firmware\\\")` for units that need the older pneumatic command bytes." ] }, { @@ -19,37 +19,12 @@ "metadata": {}, "outputs": [], "source": [ - "from pylabrobot.centrifuge import Centrifuge, create_vspin_backend\n", - "\n", - "vspin_backend = create_vspin_backend(\n", - " variant=\"agilent\",\n", - " device_id=\"YOUR_FTDI_ID_HERE\",\n", - ")\n", - "cf = Centrifuge(name=\"centrifuge\", backend=vspin_backend, size_x=1, size_y=1, size_z=1)\n", + "from pylabrobot.centrifuge import Centrifuge, VSpinBackend\n", + "vspin_backend = VSpinBackend() # VSpinBackend(device_id=\"YOUR_FTDI_ID_HERE\")\n", + "cf = Centrifuge(name = \"centrifuge\", backend = vspin_backend, size_x= 1, size_y=1, size_z=1)\n", "await cf.setup()" ] }, - { - "cell_type": "markdown", - "id": "5ac0a64e", - "metadata": {}, - "source": [ - "For a legacy Velocity11 VSpin, keep the same `Centrifuge` setup and switch the backend variant:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c55a2ee0", - "metadata": {}, - "outputs": [], - "source": [ - "legacy_vspin_backend = create_vspin_backend(\n", - " variant=\"v11\",\n", - " device_id=\"YOUR_FTDI_ID_HERE\",\n", - ")" - ] - }, { "cell_type": "markdown", "id": "e5ee122c", @@ -244,8 +219,8 @@ "source": [ "import asyncio\n", "\n", - "from pylabrobot.centrifuge import Access2, create_vspin_backend\n", - "vspin_backend = create_vspin_backend(variant=\"agilent\", device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\n", + "from pylabrobot.centrifuge import Access2, VSpinBackend\n", + "vspin_backend = VSpinBackend(device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\n", "centrifuge, loader = Access2(name=\"name\", vspin=vspin_backend, device_id=\"YOUR_LOADER_FTDI_ID_HERE\")\n", "\n", "# initialize the centrifuge and loader in parallel\n", diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index 0390308e042..f910c01a64e 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -14,4 +14,4 @@ LoaderNoPlateError, NotAtBucketError, ) -from .vspin_backend import Access2Backend, VSpinBackend, create_vspin_backend +from .vspin_backend import Access2Backend, VSpinBackend diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index c2ba0a1dc53..c05a356f9b6 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -159,8 +159,7 @@ def _load_vspin_calibrations(device_id: str) -> Optional[int]: if not os.path.exists(_vspin_bucket_calibrations_path): warnings.warn( f"No calibration found for VSpin with device id {device_id}. " - f"Using the default bucket 1 offset of home + {DEFAULT_BUCKET_1_OFFSET} ticks. " - "Use `set_bucket_1_position_to_current` after setup to override it.", + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", UserWarning, ) return None @@ -181,37 +180,11 @@ def _save_vspin_calibrations(device_id, remainder: int): FULL_ROTATION: int = 8000 -DEFAULT_BUCKET_1_OFFSET: int = 5337 -DEFAULT_BUCKET_1_REMAINDER: int = -DEFAULT_BUCKET_1_OFFSET -DEFAULT_READ_TIMEOUT: float = 0.2 -COMMAND_GAP_SECONDS: float = 0.05 -CONTROLLER_CONNECT_SETTLE_SECONDS: float = 2.0 -INITIALIZE_PACKET_GAP_SECONDS: float = 0.01 -STATUS_POLL_INTERVAL: float = 0.15 -POSITION_TOLERANCE: int = 15 -POSITION_SETTLE_TOLERANCE: int = 200 -POSITION_MOVE_ATTEMPTS: int = 2 -SPIN_START_ATTEMPTS: int = 2 -HOMING_TIMEOUT: float = 60.0 -HOMING_MIN_WAIT_SECONDS: float = 1.0 -TACH_TO_RPM: float = -14.69320388 -DOOR_OPEN_SETTLE_SECONDS: float = 2.0 -DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS: float = 0.5 -DOOR_CLOSE_SETTLE_SECONDS: float = 1.0 -PNEUMATIC_SETTLE_SECONDS: float = 0.35 - -_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x0B, 0x11, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} -_IDLE_VSPIN_STATUSES = {0x09, 0x0B, 0x11, 0x89, 0x91} - -_VSPIN_MODEL_ALIASES = { - "agilent": "agilent", - "velocity11": "velocity11", - "v11": "velocity11", -} +_KNOWN_VSPIN_STATUSES = {0x08, 0x09, 0x0B, 0x11, 0x13, 0x18, 0x19, 0x88, 0x89, 0x91, 0x99} -_VSPIN_HUMAN_READABLE_NAMES = { - "agilent": "Agilent VSpin Centrifuge", - "velocity11": "Velocity11 VSpin Centrifuge", +_VSPIN_COMMAND_SET_ALIASES = { + "agilent": "agilent", + "old_firmware": "old_firmware", } _VSPIN_COMMANDS = { @@ -223,7 +196,7 @@ def _save_vspin_calibrations(device_id, remainder: int): "lock_bucket": bytes.fromhex("aa022600072f"), "unlock_bucket": bytes.fromhex("aa022600062e"), }, - "velocity11": { + "old_firmware": { "open_door": bytes.fromhex("aa022600072f"), "close_door": bytes.fromhex("aa022600052d"), "lock_door": bytes.fromhex("aa0226000028"), @@ -234,11 +207,11 @@ def _save_vspin_calibrations(device_id, remainder: int): } -def _normalize_vspin_model(model: str) -> str: - normalized_model = model.lower() - if normalized_model not in _VSPIN_MODEL_ALIASES: - raise ValueError("model must be 'agilent', 'velocity11', or 'v11'") - return _VSPIN_MODEL_ALIASES[normalized_model] +def _normalize_vspin_command_set(command_set: str) -> str: + normalized = command_set.lower() + if normalized not in _VSPIN_COMMAND_SET_ALIASES: + raise ValueError("command_set must be 'agilent' or 'old_firmware'") + return _VSPIN_COMMAND_SET_ALIASES[normalized] def _with_vspin_checksum(cmd: bytes) -> bytes: @@ -249,62 +222,6 @@ def _with_vspin_checksum(cmd: bytes) -> bytes: return b"\xaa" + payload + bytes([sum(payload) & 0xFF]) -def _build_vspin_position_command(position: int) -> bytes: - position_bytes = int(position).to_bytes(4, byteorder="little") - payload = b"\x01\xd4\x97" + position_bytes + bytes.fromhex("c3f52800d71a0000") - return _with_vspin_checksum(b"\xaa" + payload + b"\x00") - - -def _build_vspin_spin_command( - current_position: int, - rpm: int, - duration: float, - acceleration: float, -) -> tuple[bytes, int]: - ticks_per_second = (int(rpm) / 60.0) * FULL_ROTATION - acceleration_ticks_per_second2 = 12903.2 * float(acceleration) - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - distance_at_speed = ticks_per_second * float(duration) - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - position_bytes = final_position.to_bytes(4, byteorder="little") - rpm_bytes = int(int(rpm) * 4473.925).to_bytes(4, byteorder="little") - acceleration_bytes = int(9.15 * 100 * float(acceleration)).to_bytes( - 4, byteorder="little" - ) - payload = b"\x01\xd4\x97" + position_bytes + rpm_bytes + acceleration_bytes - return _with_vspin_checksum(b"\xaa" + payload + b"\x00"), final_position - - -def _build_vspin_deceleration_command(deceleration: float) -> bytes: - deceleration_bytes = int(9.15 * 100 * float(deceleration)).to_bytes( - 2, byteorder="little" - ) - return _with_vspin_checksum( - bytes.fromhex("aa0194b600000000") + deceleration_bytes + b"\x00\x00\x00" - ) - - -def _normalize_vspin_home_position(home_position: int) -> int: - return int(home_position) % FULL_ROTATION - - -def _vspin_position_matches_target(position: int, target: int, tolerance: int) -> bool: - absolute_delta = abs(int(position) - int(target)) - if absolute_delta <= tolerance: - return True - - angular_delta = abs((int(position) % FULL_ROTATION) - (int(target) % FULL_ROTATION)) - angular_delta = min(angular_delta, FULL_ROTATION - angular_delta) - return angular_delta <= tolerance - - bucket_1_not_set_error = RuntimeError( "Bucket 1 position not set. " "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " @@ -313,105 +230,34 @@ def _vspin_position_matches_target(position: int, target: int, tolerance: int) - class VSpinBackend(CentrifugeBackend): - """Backend for Agilent and Velocity11 VSpin centrifuges. - - The Agilent and Velocity11 labels share most of the wire protocol. Known - firmware differences are isolated behind ``model`` so parsing, polling, - homing, and spin behavior stay in one implementation. - """ + """Backend for the Agilent Centrifuge. + Note that this is not a complete implementation.""" def __init__( self, device_id: Optional[str] = None, - model: str = "agilent", - try_runtime_attach_after_startup_failure: bool = False, + command_set: str = "agilent", ): """ Args: device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices`. - model: VSpin command flavor. Use ``"agilent"`` for the existing backend - behavior and ``"velocity11"`` or ``"v11"`` for older firmware. - try_runtime_attach_after_startup_failure: Try to attach to a controller - that is already in 57600-baud runtime mode before cold startup, and - retry that attach path if the normal 19200-baud startup handshake fails. - This only applies to the Velocity11 model path. + command_set: VSpin firmware command set. ``"agilent"`` is the default + used by known Agilent units and some Velocity11 units. Use + ``"old_firmware"`` for older firmware that needs the legacy pneumatic + command bytes. """ - self._model = _normalize_vspin_model(model) - self.io = FTDI( - human_readable_device_name=_VSPIN_HUMAN_READABLE_NAMES[self._model], - device_id=device_id, - ) - self._bucket_1_remainder: Optional[int] = ( - None if self._model == "agilent" else DEFAULT_BUCKET_1_REMAINDER - ) - self._last_position: int = 0 - self._last_home_position: int = 0 - self._home_sensor_position: Optional[int] = None - self._motion_is_prepared = False - self._stop_requested = False - self._command_lock: Optional[asyncio.Lock] = None - self._last_command_at = 0.0 - self._try_runtime_attach_after_startup_failure = try_runtime_attach_after_startup_failure + self._command_set = _normalize_vspin_command_set(command_set) + self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) + self._bucket_1_remainder: Optional[int] = None # only attempt loading calibration if device_id is not None # if it is None, we will load it after setup when we can query the device id from the io if device_id is not None: - calibration = _load_vspin_calibrations(device_id) - if calibration is not None: - self._bucket_1_remainder = calibration + self._bucket_1_remainder = _load_vspin_calibrations(device_id) async def setup(self): await self.io.setup() - try: - self._command_lock = asyncio.Lock() - self._motion_is_prepared = False - - if self._model == "agilent": - await self._setup_agilent() - elif self._model == "velocity11": - await self._setup_velocity11() - else: - raise ValueError(f"Unsupported VSpin model: {self._model}") - - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) - elif self._bucket_1_remainder == DEFAULT_BUCKET_1_REMAINDER: - device_id = await self.io.get_serial() - calibration = _load_vspin_calibrations(device_id) - if calibration is not None: - self._bucket_1_remainder = calibration - except Exception: - await self._close_connection_cleanly() - raise - - async def _setup_velocity11(self) -> None: - attached_to_runtime = False - if self._try_runtime_attach_after_startup_failure: - attached_to_runtime = await self._try_attach_to_runtime_controller() - if attached_to_runtime: - logger.info("[vspin] Attached to runtime controller before cold startup") - - if not attached_to_runtime: - await self.configure_and_initialize() - try: - await self._startup_handshake() - await self._enable_telemetry_and_pneumatics() - except TimeoutError as e: - if ( - self._try_runtime_attach_after_startup_failure - and await self._try_attach_to_runtime_controller() - ): - logger.info("[vspin] Recovered controller after partial startup") - else: - raise TimeoutError( - "VSpin did not respond to the 19200-baud startup handshake. " - "Power-cycle or restart the VSpin controller, then try setup again." - ) from e - - await self._home_rotor() - - async def _setup_agilent(self) -> None: + # TODO: add functionality where if robot has been initialized before nothing needs to happen for _ in range(3): await self.configure_and_initialize() await self._send_command(bytes.fromhex("aa002101ff21")) @@ -442,6 +288,7 @@ async def _setup_agilent(self) -> None: await self.lock_door() await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) await self._send_command(bytes.fromhex("aa0117041c")) @@ -457,6 +304,7 @@ async def _setup_agilent(self) -> None: while resp == 0x89: resp = (await self._get_positions_and_tachometer()).status + # --- almost the same as go to position --- await self._send_command(bytes.fromhex("aa0117021a")) await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) await self._send_command(bytes.fromhex("aa0117041c")) @@ -464,18 +312,27 @@ async def _setup_agilent(self) -> None: await self._send_command(bytes.fromhex("aa010b0c")) await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") + new_position = (0).to_bytes(4, byteorder="little") # arbitrary + # rpm = 600, + # acceleration = 75.09289617486338 await self._send_command( bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") ) + # ----------------------------------------- resp = 0x08 while resp != 0x09: resp = (await self._get_positions_and_tachometer()).status await self._send_command(bytes.fromhex("aa0117021a")) + await self.lock_door() + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + @property def bucket_1_remainder(self) -> int: if self._bucket_1_remainder is None: @@ -486,16 +343,8 @@ async def set_bucket_1_position_to_current(self) -> None: """Set the current position as bucket 1 position and save calibration.""" current_position = await self.get_position() device_id = await self.io.get_serial() - home_sensor_position = ( - self._home_sensor_position - if self._home_sensor_position is not None - else await self.get_home_position() - ) - home_sensor_position = _normalize_vspin_home_position(home_sensor_position) - home_rotations = (current_position - home_sensor_position) // FULL_ROTATION - home_position = home_sensor_position + home_rotations * FULL_ROTATION - remainder = home_position - current_position - self._bucket_1_remainder = remainder + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION _save_vspin_calibrations(device_id, remainder) async def get_bucket_1_position(self) -> int: @@ -504,45 +353,25 @@ async def get_bucket_1_position(self) -> int: The bucket 1 position must be greater than the current position, so we find the first position greater than the current position by adding full rotations if needed. """ - return await self._get_bucket_position(1) - - async def _get_bucket_position(self, bucket_num: int) -> int: - if bucket_num not in (1, 2): - raise ValueError("bucket_num must be 1 or 2") if self._bucket_1_remainder is None: raise bucket_1_not_set_error - - home_position = self._home_sensor_position - if home_position == 0: - home_position = None - if home_position is None: - live_home_position = await self.get_home_position() - if live_home_position == 0: - await self._home_rotor() - home_position = self._home_sensor_position - if home_position == 0: - home_position = None - else: - home_position = live_home_position - - if home_position is None: - raise RuntimeError("VSpin home sensor position is unknown. Run setup or _home_rotor first.") - + home_position = await self.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + # first number after current position that matches bucket 1 position mod FULL_ROTATION current_position = await self.get_position() - target_position = home_position - self.bucket_1_remainder - if bucket_num == 2: - target_position += FULL_ROTATION // 2 - - while target_position <= current_position + POSITION_TOLERANCE: - target_position += FULL_ROTATION - - return target_position + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position async def stop(self): - await self._close_connection_cleanly() + await self.configure_and_initialize() + await self.io.stop() def _command(self, name: str) -> bytes: - return _VSPIN_COMMANDS[self._model][name] + return _VSPIN_COMMANDS[self._command_set][name] class _StatusPositionTachometer(ctypes.LittleEndianStructure): _pack_ = 1 @@ -556,26 +385,6 @@ class _StatusPositionTachometer(ctypes.LittleEndianStructure): ("checksum", ctypes.c_uint8), ] - @staticmethod - def _make_status( - status: int, - current_position: int, - unknown1: int = 0, - tachometer: int = 0, - unknown2: int = 0, - home_position: int = 0, - checksum: int = 0, - ) -> _StatusPositionTachometer: - parsed = VSpinBackend._StatusPositionTachometer() - parsed.status = status - parsed.current_position = current_position - parsed.unknown1 = unknown1 - parsed.tachometer = tachometer - parsed.unknown2 = unknown2 - parsed.home_position = home_position - parsed.checksum = checksum - return parsed - @staticmethod def _find_status_packet(resp: bytes) -> Optional[_StatusPositionTachometer]: for start in range(max(0, len(resp) - 13)): @@ -587,40 +396,6 @@ def _find_status_packet(resp: bytes) -> Optional[_StatusPositionTachometer]: return VSpinBackend._StatusPositionTachometer.from_buffer_copy(packet) return None - @staticmethod - def _find_short_status(resp: bytes) -> Optional[int]: - if len(resp) == 5 and resp[0] == 0x00 and (sum(resp[:-1]) & 0xFF) == resp[-1]: - if resp[2] in _KNOWN_VSPIN_STATUSES: - return resp[2] - - for index, value in enumerate(resp): - if value not in _KNOWN_VSPIN_STATUSES: - continue - if len(resp) == 1: - return value - if index + 1 < len(resp) and resp[index + 1] == value: - return value - return None - - def _parse_position_status(self, resp: bytes) -> _StatusPositionTachometer: - full_status = self._find_status_packet(resp) - if full_status is not None: - return full_status - - short_status = self._find_short_status(resp) - if short_status is not None: - return self._make_status( - status=short_status, - current_position=self._last_position, - home_position=self._last_home_position, - ) - - return self._make_status( - status=0x19, - current_position=self._last_position, - home_position=self._last_home_position, - ) - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: """Returns 14 bytes @@ -644,21 +419,12 @@ async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - 10th to 13th byte (index 9-12) = Homing Position - Last byte (index 13) = checksum """ - resp = await self._send_command(bytes.fromhex("aa010e0f"), expected_len=14) - status = self._parse_position_status(resp) - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - return status - - async def _get_full_positions_and_tachometer(self) -> _StatusPositionTachometer: - resp = await self._send_command( - bytes.fromhex("aa01121f32"), - read_timeout=0.40, - expected_len=14, - ) - status = self._parse_position_status(resp) - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) + resp = await self._send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + status = self._find_status_packet(resp) + if status is None: + raise IOError(f"Invalid status from centrifuge: {resp.hex()}") return status async def get_position(self) -> int: @@ -666,7 +432,8 @@ async def get_position(self) -> int: async def get_tachometer(self) -> int: """current speed in rpm""" - return (await self._get_positions_and_tachometer()).tachometer * TACH_TO_RPM # type: ignore + tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM + return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore async def get_home_position(self) -> int: """changes during a run, but the bucket 1 position relative to it does not""" @@ -679,9 +446,9 @@ async def _get_status(self): - 0080f0015 """ - resp = await self._send_command(bytes.fromhex("aa020e10"), expected_len=5) - if len(resp) < 3: - raise IOError(f"Invalid status from centrifuge: {resp.hex() or '(empty)'}") + resp = await self._send_command(bytes.fromhex("aa020e10")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge. Is the machine on?") return resp async def get_bucket_locked(self) -> bool: @@ -698,787 +465,121 @@ async def get_door_locked(self) -> bool: # Centrifuge communication: read_resp, send - async def _read_resp( - self, - timeout: float = DEFAULT_READ_TIMEOUT, - expected_len: Optional[int] = None, - quiet_time: float = 0.05, - ) -> bytes: - """Read raw binary VSpin responses. - - VSpin status replies are usually 2, 5, or 14 raw bytes and do not end in - CR. Waiting for 0x0d creates avoidable timeouts on normal status polling. - """ + async def _read_resp(self, timeout: float = 20) -> bytes: + """Read a response from the centrifuge. If the timeout is reached, return the data that has + been read so far.""" data = b"" - start_time = time.monotonic() - last_data_time: Optional[float] = None + end_byte_found = False + start_time = time.time() - while time.monotonic() - start_time < timeout: + while True: chunk = await self.io.read(25) if chunk: - data += bytes(chunk) - last_data_time = time.monotonic() - if expected_len is not None and len(data) >= expected_len: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: break - continue - - if ( - data - and expected_len is None - and last_data_time is not None - and time.monotonic() - last_data_time >= quiet_time - ): - break - - await asyncio.sleep(0.003) + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) - logger.debug("[vspin] Read %s", data.hex()) + logger.debug("Read %s", data.hex()) return data - async def _send_safe( - self, - cmd: bytes, - retries: int = 3, - timeout: float = DEFAULT_READ_TIMEOUT, - expect_response: bool = True, - expected_len: Optional[int] = None, - ) -> bytes: - for attempt in range(1, retries + 1): - resp = await self._send_command( - cmd, - read_timeout=timeout, - expected_len=expected_len, - ) - if resp or not expect_response: - return resp - - logger.debug( - "[vspin] Empty response to %s (attempt %d/%d)", - cmd.hex(), - attempt, - retries, - ) - await asyncio.sleep(0.15) - - raise TimeoutError(f"No response to VSpin command {cmd.hex()}") - - @staticmethod - def _expected_response_len(cmd: bytes) -> Optional[int]: - if cmd == bytes.fromhex("aa010e0f"): - return 14 - if cmd == bytes.fromhex("aa020e10"): - return 5 - if cmd in (bytes.fromhex("aa002101ff21"), bytes.fromhex("aa002102ff22")): - return 2 - if cmd in (bytes.fromhex("aa01132034"), bytes.fromhex("aa02132035")): - return 4 - return None - - def _get_command_lock(self) -> asyncio.Lock: - if self._command_lock is None: - self._command_lock = asyncio.Lock() - return self._command_lock - - async def _send_command( - self, - cmd: bytes, - read_timeout: float = DEFAULT_READ_TIMEOUT, - expected_len: Optional[int] = None, - ) -> bytes: + async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: cmd = _with_vspin_checksum(bytes(cmd)) - expected_len = expected_len or self._expected_response_len(cmd) - lock = self._get_command_lock() - - logger.debug("[vspin] Sending %s", cmd.hex()) - async with lock: - command_gap = COMMAND_GAP_SECONDS - (time.monotonic() - self._last_command_at) - if command_gap > 0: - await asyncio.sleep(command_gap) - written = await self.io.write(cmd) - if written != len(cmd): - raise RuntimeError(f"Failed to write all bytes ({written}/{len(cmd)} bytes written)") - resp = await self._read_resp(timeout=read_timeout, expected_len=expected_len) - self._last_command_at = time.monotonic() - - logger.debug("[vspin] Response %s", resp.hex()) - return resp - - async def _startup_handshake(self) -> None: - await self._send_safe(bytes.fromhex("aa002101ff21"), timeout=0.60, expected_len=2) - await self._send_safe(bytes.fromhex("aa01132034"), timeout=0.60, expected_len=4) - await self._send_safe(bytes.fromhex("aa002102ff22"), timeout=0.60, expected_len=2) - await self._send_safe(bytes.fromhex("aa02132035"), timeout=0.60, expected_len=4) - - # The original software writes this and then tolerates silence for roughly - # two seconds while the controller transitions into its startup state. - await self._send_safe( - bytes.fromhex("aa002103ff23"), - timeout=0.15, - expect_response=False, - ) - await self._drain_startup_silence(2.0) - - await self._send_safe(bytes.fromhex("aaff1a142d"), timeout=0.12, expect_response=False) - await self._send_safe( - bytes.fromhex("aa010e0f"), - timeout=0.30, - expected_len=None, - expect_response=False, - ) - await self._send_safe( - bytes.fromhex("aa020e10"), - timeout=0.30, - expected_len=5, - expect_response=False, - ) - - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - async def _drain_startup_silence(self, seconds: float) -> None: - end = time.monotonic() + seconds - while time.monotonic() < end: - await self._read_resp(timeout=0.08, expected_len=None, quiet_time=0.01) - await asyncio.sleep(0.03) - - @staticmethod - def _is_runtime_attach_status(status: _StatusPositionTachometer) -> bool: - return int(status.current_position) != 0 or int(status.home_position) != 0 - - async def _try_attach_to_runtime_controller(self) -> bool: - """Attach to a VSpin controller that is already in 57600-baud runtime mode.""" - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - await self._purge_io_buffers() - await self._drain_startup_silence(0.25) - - for _ in range(2): - for status_command in ( - bytes.fromhex("aa010e0f"), - bytes.fromhex("aa01121f32"), - ): - resp = await self._send_command( - status_command, - read_timeout=0.40, - expected_len=14, - ) - status = self._find_status_packet(resp) - if status is not None: - if not self._is_runtime_attach_status(status): - logger.debug( - "[vspin] Ignoring blank runtime attach status " - "(status=0x%02x, position=%d, home=%d)", - status.status, - status.current_position, - status.home_position, - ) - continue - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - logger.info( - "[vspin] Attached to runtime controller " - "(status=0x%02x, position=%d, home=%d)", - status.status, - status.current_position, - status.home_position, - ) - return True - await asyncio.sleep(0.20) - - return False - - async def _enable_telemetry_and_pneumatics(self) -> None: - await self._send_safe(bytes.fromhex("aa01121f32"), timeout=0.35, expected_len=14) - - for _ in range(8): - await self._send_safe(bytes.fromhex("aa0220ff0f30"), timeout=0.12) - - for cmd in ( - bytes.fromhex("aa0220df0f10"), - bytes.fromhex("aa0220df0e0f"), - bytes.fromhex("aa0220df0c0d"), - bytes.fromhex("aa0220df0809"), - ): - await self._send_safe(cmd, timeout=0.12) - - for _ in range(4): - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.12) - - await self._send_safe(bytes.fromhex("aa02120317"), timeout=0.12) - - for _ in range(5): - await self._send_safe(bytes.fromhex("aa0226200048"), timeout=0.15) - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) - - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.12, expected_len=5) - await self._send_safe(bytes.fromhex("aa0226000129"), timeout=0.15) - await self._poll_io_status(0.35) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.15) - await self._poll_io_status(0.35) - self._motion_is_prepared = True - - async def _poll_io_status(self, seconds: float) -> None: - end = time.monotonic() + seconds - while time.monotonic() < end: - await self._send_safe(bytes.fromhex("aa020e10"), timeout=0.10, expected_len=5) - await asyncio.sleep(0.12) - - async def _motor_enable(self) -> None: - await self._send_safe(bytes.fromhex("aa0117021a"), timeout=0.30, expected_len=14) - await self._send_safe( - bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), - timeout=0.30, - expected_len=14, - ) - await self._send_safe(bytes.fromhex("aa0117041c"), timeout=0.30, expected_len=14) - await self._send_safe(bytes.fromhex("aa01170119"), timeout=0.30, expected_len=14) - await self._send_safe(bytes.fromhex("aa010b0c"), timeout=0.30, expected_len=14) - - async def _home_rotor(self) -> None: - pre_home_status = await self._get_positions_and_tachometer() - homing_started_at = time.monotonic() - await self._motor_enable() - await self._send_safe(bytes.fromhex("aa010001"), timeout=0.30, expected_len=14) - await self._send_safe( - bytes.fromhex("aa01e605006400000000003200e80301006e"), - timeout=0.30, - expected_len=14, - ) - await self._send_safe( - bytes.fromhex("aa0194b61283000012010000f3"), - timeout=0.30, - expected_len=14, - ) - await self._send_safe(bytes.fromhex("aa01192842"), timeout=0.30, expected_len=14) - - status = await self._wait_for_idle( - label="homing", - timeout=HOMING_TIMEOUT, - min_wait=HOMING_MIN_WAIT_SECONDS, - require_activity_from=pre_home_status, - activity_tolerance=POSITION_SETTLE_TOLERANCE, - ) - - if not self._has_fresh_home_reference( - status=status, - previous_home_position=int(pre_home_status.home_position), - ): - remaining_timeout = max(0.0, HOMING_TIMEOUT - (time.monotonic() - homing_started_at)) - logger.debug( - "[vspin] Waiting for fresh homing reference " - "(previous home=%d, current home=%d)", - pre_home_status.home_position, - status.home_position, - ) - status = await self._wait_for_homed_status( - pre_home_status=pre_home_status, - timeout=remaining_timeout, - ) - - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - self._home_sensor_position = _normalize_vspin_home_position(status.home_position) - - @staticmethod - def _has_fresh_home_reference( - status: _StatusPositionTachometer, - previous_home_position: int, - ) -> bool: - if int(status.home_position) == 0: - return False - if int(previous_home_position) == 0: - return True - return int(status.home_position) != int(previous_home_position) - - async def _wait_for_homed_status( - self, - pre_home_status: _StatusPositionTachometer, - timeout: float, - ) -> _StatusPositionTachometer: - end = time.monotonic() + timeout - previous_home_position = int(pre_home_status.home_position) - last_status: Optional[VSpinBackend._StatusPositionTachometer] = None - - while time.monotonic() < end: - status = await self._get_full_positions_and_tachometer() - last_status = status - is_idle_status = status.status in _IDLE_VSPIN_STATUSES - is_stopped = abs(status.tachometer) <= 2 - if ( - is_idle_status - and is_stopped - and self._has_fresh_home_reference(status, previous_home_position) - ): - return status - await asyncio.sleep(STATUS_POLL_INTERVAL) - - if last_status is None: - last_status = self._make_status( - 0x19, - self._last_position, - home_position=self._last_home_position, - ) - raise TimeoutError( - "VSpin homing did not report a fresh home position within " - f"{timeout:.1f}s (previous home={previous_home_position}, " - f"last status=0x{last_status.status:02x}, " - f"position={last_status.current_position}, " - f"tachometer={last_status.tachometer}, " - f"home={last_status.home_position})" - ) - - async def _wait_for_full_status( - self, - timeout: float, - allow_zero_home_fallback: bool = False, - ) -> _StatusPositionTachometer: - end = time.monotonic() + timeout - last_raw = b"" - last_full_status: Optional[VSpinBackend._StatusPositionTachometer] = None - while time.monotonic() < end: - resp = await self._send_command( - bytes.fromhex("aa010e0f"), - read_timeout=0.40, - expected_len=14, - ) - last_raw = resp - status = self._find_status_packet(resp) - if status is not None and status.home_position != 0: - self._last_position = int(status.current_position) - self._last_home_position = int(status.home_position) - return status - if status is not None: - last_full_status = status - await asyncio.sleep(0.10) - - if allow_zero_home_fallback and last_full_status is not None: - self._last_position = int(last_full_status.current_position) - self._last_home_position = int(last_full_status.home_position) - return last_full_status - - raise TimeoutError( - "VSpin homing reached idle, but no full 14-byte status packet with a " - f"home position was received. Last raw response: {last_raw.hex() or '(empty)'}" - ) - - async def _wait_for_idle( - self, - label: str, - timeout: float, - target_position: Optional[int] = None, - tolerance: int = POSITION_TOLERANCE, - min_wait: float = 0.0, - require_activity_from: Optional[_StatusPositionTachometer] = None, - activity_tolerance: int = POSITION_TOLERANCE, - ) -> _StatusPositionTachometer: - start = time.monotonic() - last_status = self._make_status( - 0x19, - self._last_position, - home_position=self._last_home_position, - ) - observed_activity = require_activity_from is None - - while time.monotonic() - start <= timeout: - status = await self._get_positions_and_tachometer() - last_status = status - is_idle_status = status.status in _IDLE_VSPIN_STATUSES - is_stopped = abs(status.tachometer) <= 2 - should_probe_full_status = is_idle_status and is_stopped and ( - (require_activity_from is not None and not observed_activity) - or ( - target_position is not None - and not _vspin_position_matches_target( - position=int(status.current_position), - target=int(target_position), - tolerance=tolerance, - ) - ) - ) - if should_probe_full_status: - full_status = await self._get_full_positions_and_tachometer() - if full_status.status in _IDLE_VSPIN_STATUSES and abs(full_status.tachometer) <= 2: - status = full_status - last_status = status - is_idle_status = True - is_stopped = True - - if require_activity_from is not None and not observed_activity: - observed_activity = ( - not is_idle_status - or not is_stopped - or not _vspin_position_matches_target( - position=int(status.current_position), - target=int(require_activity_from.current_position), - tolerance=activity_tolerance, - ) - or ( - int(status.home_position) != 0 - and int(status.home_position) != int(require_activity_from.home_position) - ) - ) - is_at_target = ( - target_position is None - or _vspin_position_matches_target( - position=int(status.current_position), - target=int(target_position), - tolerance=tolerance, - ) - ) - - if ( - is_idle_status - and is_stopped - and is_at_target - and observed_activity - and time.monotonic() - start >= min_wait - ): - return status - - await asyncio.sleep(STATUS_POLL_INTERVAL) - raise TimeoutError( - f"VSpin {label} did not become idle within {timeout:.1f}s " - f"(status=0x{last_status.status:02x}, position={last_status.current_position}, " - f"tachometer={last_status.tachometer}, home={last_status.home_position})" - ) - - async def _prepare_bucket_motion(self) -> None: - if self._model == "agilent": - await self._send_safe(self._command("lock_door"), timeout=0.20) - self._motion_is_prepared = True - return - - await self._send_safe(self._command("lock_bucket"), timeout=0.20) - await self._wait_for_io_state( - label="bucket motion lock", - timeout=1.5, - door_open=False, - door_locked=True, - bucket_locked=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._wait_for_io_state( - label="bucket motion ready", - timeout=2.5, - door_open=False, - door_locked=True, - bucket_locked=False, - settled=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True - - async def _prepare_spin_motion(self) -> None: - if self._model == "agilent": - await self._send_safe(self._command("lock_door"), timeout=0.20) - self._motion_is_prepared = True - return - - await self._send_safe(self._command("lock_bucket"), timeout=0.20) - await self._wait_for_io_state( - label="spin bucket lock", - timeout=1.5, - door_open=False, - door_locked=True, - bucket_locked=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - await self._send_safe(bytes.fromhex("aa0226000028"), timeout=0.20) - await self._wait_for_io_state( - label="spin ready", - timeout=2.5, - door_open=False, - door_locked=True, - bucket_locked=False, - settled=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True - - async def _wait_for_io_state( - self, - label: str, - timeout: float, - door_open: Optional[bool] = None, - door_locked: Optional[bool] = None, - bucket_locked: Optional[bool] = None, - settled: bool = False, - ) -> None: - end = time.monotonic() + timeout - last_status = b"" - while time.monotonic() < end: - try: - last_status = await self._get_status() - except IOError: - await asyncio.sleep(0.05) - continue - - if len(last_status) >= 3: - value = last_status[2] - matches = [] - if door_open is not None: - matches.append((value & 0b0010 != 0) is door_open) - if door_locked is not None: - matches.append((value & 0b0100 == 0) is door_locked) - if bucket_locked is not None: - matches.append((value & 0b0001 != 0) is bucket_locked) - if settled: - matches.append(last_status[1] in (0x00, 0x10)) - if all(matches): - return - - await asyncio.sleep(0.05) - - raise TimeoutError( - f"VSpin {label} IO state was not reached within {timeout:.1f}s " - f"(last status={last_status.hex() or '(empty)'})" - ) - - async def _send_deceleration(self, deceleration: float) -> None: - await self._send_safe( - bytes.fromhex("aa01e60500640000000000fd00803e01000c"), - timeout=0.25, - ) - await self._send_safe(_build_vspin_deceleration_command(deceleration), timeout=0.25) - - async def _wait_for_door(self, open_expected: bool, timeout: float) -> None: - end = time.monotonic() + timeout - while time.monotonic() < end: - try: - if await self.get_door_open() is open_expected: - return - except IOError: - pass - await asyncio.sleep(0.12) - - expected = "open" if open_expected else "closed" - raise TimeoutError(f"VSpin door did not report {expected} within {timeout:.1f}s") - - async def _wait_for_speed_or_motion( - self, - rpm: int, - final_position: int, - timeout: float = 25.0, - ) -> None: - deadline = time.monotonic() + timeout - last_status = self._make_status( - 0x19, - self._last_position, - home_position=self._last_home_position, - ) - last_live_rpm = 0.0 - while time.monotonic() < deadline and not self._stop_requested: - status = await self._get_positions_and_tachometer() - last_status = status - last_live_rpm = abs(status.tachometer * TACH_TO_RPM) - if last_live_rpm >= rpm * 0.92: - return - await asyncio.sleep(0.25) - - raise TimeoutError( - f"VSpin did not reach target speed {rpm} rpm within {timeout:.1f}s " - f"(last rpm={last_live_rpm:.1f}, position={last_status.current_position}, " - f"home={last_status.home_position})" - ) + written = await self.io.write(cmd) - async def _hold_spin(self, duration: float) -> None: - started = time.monotonic() - while not self._stop_requested and time.monotonic() - started < duration: - await self._get_positions_and_tachometer() - await asyncio.sleep(min(1.0, duration)) + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) async def configure_and_initialize(self): await self.set_configuration_data() - await self._settle_controller_connection() await self.initialize() async def set_configuration_data(self): """Set the device configuration data.""" - await self._set_serial_line_defaults() - await self.io.set_baudrate(19200) - - async def _set_serial_line_defaults(self) -> None: await self.io.set_latency_timer(16) await self.io.set_line_property(bits=8, stopbits=1, parity=0) await self.io.set_flowctrl(0) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - async def _settle_controller_connection(self) -> None: - await asyncio.sleep(CONTROLLER_CONNECT_SETTLE_SECONDS) - - async def _purge_io_buffers(self) -> None: - try: - await self.io.usb_purge_rx_buffer() - await self.io.usb_purge_tx_buffer() - except Exception as e: - logger.debug("[vspin] Ignoring buffer purge during close/setup: %s", e) - - async def _close_connection_cleanly(self) -> None: - try: - await asyncio.sleep(0.20) - await self._purge_io_buffers() - try: - await self.io.set_dtr(False) - await self.io.set_rts(False) - except Exception as e: - logger.debug("[vspin] Ignoring control-line reset during close: %s", e) - await asyncio.sleep(0.20) - finally: - await self.io.stop() + await self.io.set_baudrate(19200) async def initialize(self): - for _ in range(2): - await self.io.write(b"\x00" * 20) - await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await asyncio.sleep(INITIALIZE_PACKET_GAP_SECONDS) - await self._send_command(bytes.fromhex("aaff0f0e"), read_timeout=0.08) - await asyncio.sleep(COMMAND_GAP_SECONDS) + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e")) # Centrifuge operations async def open_door(self): - try: - if await self.get_door_open(): - await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) - return - except IOError: - pass - - try: - await self.unlock_door() - except IOError: - pass - - await self._send_safe(self._command("open_door"), timeout=0.30) - await self._wait_for_door(open_expected=True, timeout=4.0) - await asyncio.sleep(DOOR_OPEN_SETTLE_SECONDS) + if await self.get_door_open(): + return + await self._send_command(self._command("open_door")) # same as unlock door on new firmware - async def close_door(self): - try: - if not await self.get_door_open(): - return - except IOError: - pass + # we can't tell when the door is fully open, so we just wait a bit + await asyncio.sleep(4) - await self._send_safe(self._command("close_door"), timeout=0.30) - await self._wait_for_door(open_expected=False, timeout=4.0) - await asyncio.sleep(DOOR_CLOSE_SETTLE_SECONDS) - self._motion_is_prepared = False + async def close_door(self): + if not (await self.get_door_open()): + return + await self._send_command(self._command("close_door")) # same as unlock door on new firmware + # we can't tell when the door is fully closed, so we just wait a bit + await asyncio.sleep(2) async def lock_door(self): if await self.get_door_open(): raise RuntimeError("Cannot lock door while it is open.") if await self.get_door_locked(): return - await self._send_safe(self._command("lock_door"), timeout=0.20) - if self._model == "agilent": - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - return - await self._wait_for_io_state( - label="door lock", - timeout=2.5, - door_open=False, - door_locked=True, - bucket_locked=False, - settled=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) + await self._send_command(self._command("lock_door")) async def unlock_door(self): if not await self.get_door_locked(): return - await self._send_safe(self._command("unlock_door"), timeout=0.20) - await asyncio.sleep(DOOR_UNLOCK_TO_OPEN_SETTLE_SECONDS) + await self._send_command(self._command("unlock_door")) # same as close door async def lock_bucket(self): if await self.get_bucket_locked(): return - await self._send_safe(self._command("lock_bucket"), timeout=0.25) - if self._model == "agilent": - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = False - return - await self._wait_for_io_state( - label="bucket lock", - timeout=1.5, - bucket_locked=True, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = False + await self._send_command(self._command("lock_bucket")) async def unlock_bucket(self): if not await self.get_bucket_locked(): return - await self._send_safe(self._command("unlock_bucket"), timeout=0.25) - if self._model == "agilent": - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True - return - await self._wait_for_io_state( - label="bucket unlock", - timeout=1.5, - bucket_locked=False, - ) - await asyncio.sleep(PNEUMATIC_SETTLE_SECONDS) - self._motion_is_prepared = True + await self._send_command(self._command("unlock_bucket")) # same as open door on new firmware async def go_to_bucket1(self): - await self.go_to_position(await self._get_bucket_position(1)) + await self.go_to_position(await self.get_bucket_1_position()) async def go_to_bucket2(self): - await self.go_to_position(await self._get_bucket_position(2)) + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) async def go_to_position(self, position: int): - position = int(position) - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if not self._motion_is_prepared: - await self._prepare_bucket_motion() - - for attempt in range(1, POSITION_MOVE_ATTEMPTS + 1): - if attempt > 1: - logger.warning( - "[vspin] Retrying position move to %d after wrong idle position", - position, - ) - self._motion_is_prepared = False - await self._prepare_bucket_motion() - - await self._motor_enable() - await self._send_safe( - bytes.fromhex("aa01e6c800b00496000f004b00a00f050007"), - timeout=0.20, - expected_len=14, - ) - await self._send_safe( - _build_vspin_position_command(position), - timeout=0.25, - expected_len=14, - ) - try: - await self._wait_for_idle( - label=f"position {position}", - timeout=25.0, - target_position=position, - tolerance=POSITION_SETTLE_TOLERANCE, - ) - break - except TimeoutError: - if attempt == POSITION_MOVE_ATTEMPTS: - raise - - await self.lock_bucket() + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = _with_vspin_checksum( + bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") + ) + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(byte_string) + + # await self._send_command(bytes.fromhex("aa0117021a")) + while ( + abs(await self.get_position() - position) > 10 + ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) + await asyncio.sleep(0.1) await self.open_door() @staticmethod @@ -1488,12 +589,6 @@ def g_to_rpm(g: float) -> int: rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) return rpm - @staticmethod - def rpm_to_g(rpm: float) -> float: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - return 1.118 * 10**-5 * r * float(rpm) ** 2 - async def spin( self, g: float = 500, @@ -1519,107 +614,113 @@ async def spin( if duration < 1: raise ValueError("Spin time must be at least 1 second") - await self.spin_rpm( - rpm=VSpinBackend.g_to_rpm(g), - duration=duration, - acceleration=acceleration, - deceleration=deceleration, + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + # 1 - compute the final position + rpm = VSpinBackend.g_to_rpm(g) + + # compute the distance traveled during the acceleration period + # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max + # 12903.2 ticks/s^2 is 100% acceleration + acceleration_ticks_per_second2 = 12903.2 * acceleration + rounds_per_second = rpm / 60 + ticks_per_second = rounds_per_second * 8000 + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + + # compute the distance traveled at speed + distance_at_speed = ticks_per_second * duration + + current_position = await self.get_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + # this is almost 3 hours of spinning at 3000 rpm (max speed), + # so we assume nobody will ever hit this. + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + # 2 - send "go to position" command with computed final position and rpm + position_b = final_position.to_bytes(4, byteorder="little") + rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") + acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") + + byte_string = _with_vspin_checksum( + bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b + b"\x00" ) - async def spin_rpm( - self, - rpm: int, - duration: float, - acceleration: float = 0.8, - deceleration: float = 0.8, - ) -> None: - """Start a spin cycle at a target RPM. + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - This is a convenience wrapper around the same command path used by - :meth:`spin`. The public PLR centrifuge API remains g-based. - """ - rpm = int(rpm) - duration = float(duration) + await self._send_command(byte_string) - if rpm < 1 or rpm > 3000: - raise ValueError("RPM must be within 1-3000.") - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") + # 3 - wait for acceleration to the set rpm + # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) + while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + await asyncio.sleep(0.1) - if await self.get_door_open(): - await self.close_door() + # 4 - once the speed is reached, compute the position at which to start deceleration + # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. + # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. + # this is what the vendor software does too. + # if we are already past that position, we skip this part. + if await self.get_position() < final_position: + decel_start_position = await self.get_position() + distance_at_speed - self._stop_requested = False - - try: - for attempt in range(1, SPIN_START_ATTEMPTS + 1): - try: - await self._prepare_spin_motion() - await self._motor_enable() - # Sample before entering the spin profile; D4 97 must follow it immediately. - current_position = await self.get_position() - await self._send_safe( - bytes.fromhex("aa01e60500640000000000fd00803e01000c"), - timeout=0.25, - expected_len=14, - ) - - spin_command, final_position = _build_vspin_spin_command( - current_position=current_position, - rpm=rpm, - duration=duration, - acceleration=acceleration, - ) - await self._send_safe(spin_command, timeout=0.25, expected_len=14) - - await self._wait_for_speed_or_motion(rpm=rpm, final_position=final_position) - await self._hold_spin(duration) - await self._send_deceleration(deceleration) - break - except TimeoutError: - if attempt == SPIN_START_ATTEMPTS: - raise - logger.warning("[vspin] Retrying spin start after target speed was not reached") - await self._send_deceleration(deceleration) - await self._wait_for_idle(label="spin retry rundown", timeout=45.0) - self._motion_is_prepared = False - except asyncio.CancelledError: - await self._send_deceleration(deceleration) - raise - except Exception: - logger.exception("[vspin] Spin failed; attempting deceleration") - try: - await self._send_deceleration(deceleration) - except Exception: - logger.exception("[vspin] Deceleration after failed spin also failed") - raise - finally: - self._stop_requested = False - - await self._wait_for_idle(label="spin rundown", timeout=90.0) - await self._home_rotor() - - -def create_vspin_backend( - device_id: Optional[str] = None, - variant: str = "agilent", - try_runtime_attach_after_startup_failure: bool = False, -) -> CentrifugeBackend: - """Create a VSpin backend for the selected centrifuge generation. - - ``variant`` accepts ``"agilent"``, ``"velocity11"``, or ``"v11"``. All - variants use :class:`VSpinBackend`; only firmware-specific command bytes are - selected differently. - """ - return VSpinBackend( - device_id=device_id, - model=variant, - try_runtime_attach_after_startup_failure=try_runtime_attach_after_startup_failure, - ) + # then wait until we reach that position + while await self.get_position() < decel_start_position: + await asyncio.sleep(0.1) + + # 5 - send deceleration command + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + # aa0194b600000000dc02000029: decel at 80 + # aa0194b6000000000a03000058: decel at 85 + # aa0194b61283000012010000f3: used in setup (30%) + decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") + decel_command = _with_vspin_checksum( + bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("000000") + ) + await self._send_command(decel_command) + + await asyncio.sleep(2) + + # 6 - reset position back to 0ish + # this part is aneeded because otherwise calling go_to_position will not work after + async def _reset_to_zero(): + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again + + await _reset_to_zero() + + # 7 - wait for home position to change + # go_to_bucket{1,2} does not work until the home position changes + start = await self.get_home_position() + num_tries = 0 + while await self.get_home_position() == start: + await asyncio.sleep(0.1) + num_tries += 1 + if num_tries % 25 == 0: + await _reset_to_zero() + if num_tries > 100: + raise RuntimeError("Home position did not change after spin.") # Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 5549c0fa328..5f38fbef22b 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -1,18 +1,7 @@ import unittest from unittest import mock -from pylabrobot.centrifuge.vspin_backend import ( - DEFAULT_BUCKET_1_REMAINDER, - HOMING_TIMEOUT, - POSITION_SETTLE_TOLERANCE, - POSITION_TOLERANCE, - VSpinBackend, - _IDLE_VSPIN_STATUSES, - _build_vspin_deceleration_command, - _normalize_vspin_home_position, - _vspin_position_matches_target, - create_vspin_backend, -) +from pylabrobot.centrifuge.vspin_backend import VSpinBackend, _with_vspin_checksum class _FakeIO: @@ -30,46 +19,11 @@ async def write(self, data: bytes) -> int: return len(data) -class _RecordingConfigIO: - def __init__(self): - self.calls = [] - - async def set_latency_timer(self, latency: int): - self.calls.append(("set_latency_timer", latency)) - - async def set_line_property(self, bits: int, stopbits: int, parity: int): - self.calls.append(("set_line_property", bits, stopbits, parity)) - - async def set_flowctrl(self, flowctrl: int): - self.calls.append(("set_flowctrl", flowctrl)) - - async def set_rts(self, level: bool): - self.calls.append(("set_rts", level)) - - async def set_dtr(self, level: bool): - self.calls.append(("set_dtr", level)) - - async def set_baudrate(self, baudrate: int): - self.calls.append(("set_baudrate", baudrate)) - - -def _make_raw_backend(model: str = "velocity11") -> VSpinBackend: - backend = object.__new__(VSpinBackend) - backend._model = model - return backend - - def _make_backend(io: _FakeIO) -> VSpinBackend: - backend = _make_raw_backend() + backend = object.__new__(VSpinBackend) backend.io = io - backend._command_lock = None - backend._last_position = 0 - backend._last_home_position = 0 - backend._home_sensor_position = None - backend._bucket_1_remainder = DEFAULT_BUCKET_1_REMAINDER - backend._motion_is_prepared = False - backend._stop_requested = False - backend._last_command_at = 0.0 + backend._command_set = "agilent" + backend._bucket_1_remainder = None return backend @@ -90,21 +44,47 @@ def _status_packet( return packet + bytes([sum(packet) & 0xFF]) -class VSpinBackendTests(unittest.IsolatedAsyncioTestCase): - async def test_read_resp_returns_expected_binary_packet_without_cr(self): - backend = _make_backend(_FakeIO([b"\x00\x30", b"\x08\x30\x68"])) +class VSpinCommandSetTests(unittest.IsolatedAsyncioTestCase): + def test_default_command_set_is_agilent(self): + with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): + backend = VSpinBackend() - resp = await backend._read_resp(timeout=1.0, expected_len=5) + self.assertEqual(backend._command_set, "agilent") + self.assertEqual(backend._command("open_door"), bytes.fromhex("aa022600062e")) + self.assertEqual(backend._command("lock_bucket"), bytes.fromhex("aa022600072f")) - self.assertEqual(resp, bytes.fromhex("0030083068")) + def test_old_firmware_command_set_uses_legacy_pneumatic_commands(self): + with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): + backend = VSpinBackend(command_set="old_firmware") + + self.assertEqual(backend._command_set, "old_firmware") + self.assertEqual(backend._command("open_door"), bytes.fromhex("aa022600072f")) + self.assertEqual(backend._command("close_door"), bytes.fromhex("aa022600052d")) + self.assertEqual(backend._command("lock_bucket"), bytes.fromhex("aa0226000129")) + self.assertEqual(backend._command("unlock_bucket"), bytes.fromhex("aa0226200048")) + + def test_velocity11_label_is_not_a_command_set(self): + with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): + with self.assertRaisesRegex(ValueError, "command_set"): + VSpinBackend(command_set="velocity11") - async def test_send_command_repairs_checksum_and_uses_expected_length(self): - io = _FakeIO([bytes.fromhex("0030083068")]) + def test_unknown_command_set_raises(self): + with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): + with self.assertRaisesRegex(ValueError, "command_set"): + VSpinBackend(command_set="velocity11-label") + + def test_with_vspin_checksum_repairs_final_byte(self): + self.assertEqual( + _with_vspin_checksum(bytes.fromhex("aa020e00")), + bytes.fromhex("aa020e10"), + ) + + async def test_send_command_repairs_checksum_before_write(self): + io = _FakeIO([b"\r"]) backend = _make_backend(io) - resp = await backend._send_command(bytes.fromhex("aa020e00")) + await backend._send_command(bytes.fromhex("aa020e00")) - self.assertEqual(resp, bytes.fromhex("0030083068")) self.assertEqual(io.writes, [bytes.fromhex("aa020e10")]) def test_find_status_packet_scans_noise_and_validates_checksum(self): @@ -123,786 +103,3 @@ def test_find_status_packet_rejects_bad_checksum(self): packet[-1] ^= 0xFF self.assertIsNone(VSpinBackend._find_status_packet(bytes(packet))) - - def test_find_short_status_from_io_packet(self): - self.assertEqual(VSpinBackend._find_short_status(bytes.fromhex("0030083068")), 0x08) - - def test_status_0x89_is_idle_when_stopped_at_target(self): - self.assertIn(0x89, _IDLE_VSPIN_STATUSES) - - def test_home_position_is_normalized_to_one_rotation(self): - self.assertEqual(_normalize_vspin_home_position(839825), 7825) - - def test_position_settle_tolerance_allows_mechanical_stop_variance(self): - self.assertGreaterEqual(POSITION_SETTLE_TOLERANCE, 29) - self.assertGreater(POSITION_SETTLE_TOLERANCE, POSITION_TOLERANCE) - - def test_position_target_matching_handles_rotation_wrap(self): - self.assertTrue( - _vspin_position_matches_target( - position=5395, - target=13258, - tolerance=POSITION_SETTLE_TOLERANCE, - ) - ) - - async def test_wait_for_full_status_can_accept_zero_home_fallback(self): - backend = _make_backend( - _FakeIO( - [ - _status_packet( - status=0x89, - current_position=5548, - tachometer=-1, - home_position=0, - ) - ] - ) - ) - - status = await backend._wait_for_full_status(timeout=0.01, allow_zero_home_fallback=True) - - self.assertEqual(status.home_position, 0) - self.assertEqual(status.current_position, 5548) - - async def test_status_poll_does_not_overwrite_home_sensor_reference(self): - backend = _make_backend( - _FakeIO( - [ - _status_packet( - status=0x09, - current_position=8025, - tachometer=0, - home_position=5312, - ) - ] - ) - ) - backend._home_sensor_position = 2710 - - await backend._get_positions_and_tachometer() - - self.assertEqual(backend._home_sensor_position, 2710) - - async def test_default_bucket_positions_follow_home_offset(self): - backend = _make_backend(_FakeIO([])) - backend._home_sensor_position = 6733 - - async def get_home_position() -> int: - return 9999 - - async def get_position() -> int: - return 7000 - - backend.get_home_position = get_home_position - backend.get_position = get_position - - self.assertEqual(await backend._get_bucket_position(1), 12070) - self.assertEqual(await backend._get_bucket_position(2), 16070) - - async def test_bucket_2_position_does_not_skip_when_bucket_1_is_behind_current_position(self): - backend = _make_backend(_FakeIO([])) - backend._home_sensor_position = 6733 - - async def get_home_position() -> int: - return 9999 - - async def get_position() -> int: - return 14000 - - backend.get_home_position = get_home_position - backend.get_position = get_position - - self.assertEqual(await backend._get_bucket_position(2), 16070) - - async def test_bucket_positions_use_homed_reference_when_live_home_changes(self): - backend = _make_backend(_FakeIO([])) - backend._home_sensor_position = 7936 - - async def get_home_position() -> int: - return 12115 - - async def get_position() -> int: - return 13287 - - backend.get_home_position = get_home_position - backend.get_position = get_position - - self.assertEqual(await backend._get_bucket_position(2), 17273) - - def test_deceleration_command_uses_observed_checksum(self): - self.assertEqual( - _build_vspin_deceleration_command(0.8), - bytes.fromhex("aa0194b600000000dc02000029"), - ) - - def test_rpm_to_g_roundtrips_1500_rpm(self): - g = VSpinBackend.rpm_to_g(1500) - - self.assertEqual(VSpinBackend.g_to_rpm(g), 1500) - - async def test_spin_uses_rpm_command_path(self): - backend = _make_raw_backend() - backend.spin_rpm = mock.AsyncMock() - - await VSpinBackend.spin( - backend, - g=VSpinBackend.rpm_to_g(1500), - duration=10, - acceleration=0.7, - deceleration=0.6, - ) - - backend.spin_rpm.assert_awaited_once_with( - rpm=1500, - duration=10, - acceleration=0.7, - deceleration=0.6, - ) - - async def test_spin_rpm_validates_target_rpm(self): - backend = _make_raw_backend() - - with self.assertRaises(ValueError): - await VSpinBackend.spin_rpm(backend, rpm=0, duration=10) - - async def test_setup_uses_cold_startup_without_runtime_probe_by_default(self): - backend = _make_raw_backend() - backend.io = mock.Mock() - backend.io.setup = mock.AsyncMock() - backend.io.stop = mock.AsyncMock() - backend.io.usb_purge_rx_buffer = mock.AsyncMock() - backend.io.usb_purge_tx_buffer = mock.AsyncMock() - backend.io.set_dtr = mock.AsyncMock() - backend.io.set_rts = mock.AsyncMock() - backend._bucket_1_remainder = 123 - backend._try_runtime_attach_after_startup_failure = False - events = [] - - async def configure_and_initialize(): - events.append("cold-start") - - async def startup_handshake(): - events.append("startup") - - async def enable_telemetry_and_pneumatics(): - events.append("telemetry") - - async def home_rotor(): - events.append("home") - - backend._try_attach_to_runtime_controller = mock.AsyncMock() - backend.configure_and_initialize = configure_and_initialize - backend._startup_handshake = startup_handshake - backend._enable_telemetry_and_pneumatics = enable_telemetry_and_pneumatics - backend._home_rotor = home_rotor - - await VSpinBackend.setup(backend) - - self.assertEqual(events, ["cold-start", "startup", "telemetry", "home"]) - backend._try_attach_to_runtime_controller.assert_not_awaited() - - async def test_setup_closes_io_with_clear_error_when_controller_never_responds(self): - backend = _make_raw_backend() - backend.io = mock.Mock() - backend.io.setup = mock.AsyncMock() - backend.io.stop = mock.AsyncMock() - backend.io.usb_purge_rx_buffer = mock.AsyncMock() - backend.io.usb_purge_tx_buffer = mock.AsyncMock() - backend.io.set_dtr = mock.AsyncMock() - backend.io.set_rts = mock.AsyncMock() - backend._bucket_1_remainder = 123 - backend._try_runtime_attach_after_startup_failure = False - - backend._try_attach_to_runtime_controller = mock.AsyncMock(return_value=False) - backend.configure_and_initialize = mock.AsyncMock() - backend._startup_handshake = mock.AsyncMock(side_effect=TimeoutError("empty")) - backend._enable_telemetry_and_pneumatics = mock.AsyncMock() - backend._home_rotor = mock.AsyncMock() - - with self.assertRaisesRegex(TimeoutError, "Power-cycle or restart"): - await VSpinBackend.setup(backend) - - backend._try_attach_to_runtime_controller.assert_not_awaited() - backend.io.stop.assert_awaited_once() - - async def test_setup_can_optionally_recover_with_runtime_attach_after_startup_failure(self): - backend = _make_raw_backend() - backend.io = mock.Mock() - backend.io.setup = mock.AsyncMock() - backend.io.stop = mock.AsyncMock() - backend._bucket_1_remainder = 123 - backend._try_runtime_attach_after_startup_failure = True - events = [] - - async def configure_and_initialize(): - events.append("cold-start") - - async def startup_handshake(): - events.append("startup") - raise TimeoutError("empty") - - attach_results = [False, True] - - async def try_attach(): - events.append("attach") - return attach_results.pop(0) - async def home_rotor(): - events.append("home") - - backend.configure_and_initialize = configure_and_initialize - backend._startup_handshake = startup_handshake - backend._enable_telemetry_and_pneumatics = mock.AsyncMock() - backend._try_attach_to_runtime_controller = try_attach - backend._home_rotor = home_rotor - - await VSpinBackend.setup(backend) - - self.assertEqual(events, ["attach", "cold-start", "startup", "attach", "home"]) - - async def test_setup_tries_runtime_attach_before_cold_start_when_enabled(self): - backend = _make_raw_backend() - backend.io = mock.Mock() - backend.io.setup = mock.AsyncMock() - backend.io.get_serial = mock.AsyncMock(return_value="TEST") - backend._bucket_1_remainder = 123 - backend._try_runtime_attach_after_startup_failure = True - backend._command_lock = None - backend._motion_is_prepared = False - backend._try_attach_to_runtime_controller = mock.AsyncMock(return_value=True) - backend.configure_and_initialize = mock.AsyncMock() - backend._startup_handshake = mock.AsyncMock() - backend._enable_telemetry_and_pneumatics = mock.AsyncMock() - backend._home_rotor = mock.AsyncMock() - - await VSpinBackend.setup(backend) - - backend._try_attach_to_runtime_controller.assert_awaited_once() - backend.configure_and_initialize.assert_not_awaited() - backend._startup_handshake.assert_not_awaited() - backend._enable_telemetry_and_pneumatics.assert_not_awaited() - backend._home_rotor.assert_awaited_once() - - async def test_runtime_attach_tries_multiple_status_commands(self): - backend = _make_backend(_FakeIO([])) - backend.io.set_baudrate = mock.AsyncMock() - backend.io.set_rts = mock.AsyncMock() - backend.io.set_dtr = mock.AsyncMock() - backend._purge_io_buffers = mock.AsyncMock() - backend._drain_startup_silence = mock.AsyncMock() - commands = [] - - async def send_command(cmd: bytes, read_timeout: float, expected_len: int): - commands.append(cmd) - if cmd == bytes.fromhex("aa01121f32"): - return _status_packet() - return b"" - - backend._send_command = send_command - - attached = await backend._try_attach_to_runtime_controller() - - self.assertTrue(attached) - self.assertEqual( - commands, - [ - bytes.fromhex("aa010e0f"), - bytes.fromhex("aa01121f32"), - ], - ) - self.assertEqual(backend._last_position, 12070) - self.assertEqual(backend._last_home_position, 6733) - - async def test_runtime_attach_rejects_blank_zero_status(self): - backend = _make_backend(_FakeIO([])) - backend.io.set_baudrate = mock.AsyncMock() - backend.io.set_rts = mock.AsyncMock() - backend.io.set_dtr = mock.AsyncMock() - backend._purge_io_buffers = mock.AsyncMock() - backend._drain_startup_silence = mock.AsyncMock() - - async def send_command(cmd: bytes, read_timeout: float, expected_len: int): - return _status_packet(status=0x09, current_position=0, tachometer=0, home_position=0) - - backend._send_command = send_command - - self.assertFalse(await backend._try_attach_to_runtime_controller()) - self.assertEqual(backend._last_position, 0) - self.assertEqual(backend._last_home_position, 0) - - async def test_wait_for_idle_uses_full_status_when_short_idle_has_no_position(self): - full_status = _status_packet( - status=0x09, - current_position=8006, - tachometer=0, - home_position=7924, - ) - backend = _make_backend(_FakeIO([bytes.fromhex("0909"), full_status])) - reference = backend._make_status(0x09, 0, home_position=0) - - status = await backend._wait_for_idle( - label="homing", - timeout=1.0, - require_activity_from=reference, - activity_tolerance=POSITION_SETTLE_TOLERANCE, - ) - - self.assertEqual(status.current_position, 8006) - self.assertEqual(status.home_position, 7924) - self.assertEqual( - backend.io.writes, - [bytes.fromhex("aa010e0f"), bytes.fromhex("aa01121f32")], - ) - - async def test_wait_for_idle_can_reject_stale_idle_before_homing_activity(self): - stale_status = _status_packet( - status=0x09, - current_position=1000, - tachometer=0, - home_position=250, - ) - backend = _make_backend(_FakeIO([stale_status])) - backend._last_position = 1000 - backend._last_home_position = 250 - reference = backend._make_status(0x09, 1000, home_position=250) - - with self.assertRaisesRegex(TimeoutError, "homing"): - await backend._wait_for_idle( - label="homing", - timeout=0.01, - require_activity_from=reference, - activity_tolerance=POSITION_SETTLE_TOLERANCE, - ) - - async def test_home_rotor_requires_nonzero_home_position_after_idle(self): - backend = _make_backend(_FakeIO([])) - backend._get_positions_and_tachometer = mock.AsyncMock( - return_value=backend._make_status(0x09, 1000, home_position=0), - ) - backend._motor_enable = mock.AsyncMock() - backend._send_safe = mock.AsyncMock() - backend._wait_for_idle = mock.AsyncMock( - return_value=backend._make_status(0x09, 1000, home_position=0), - ) - backend._wait_for_homed_status = mock.AsyncMock( - return_value=backend._make_status(0x09, 1000, home_position=7859), - ) - - await backend._home_rotor() - - backend._wait_for_homed_status.assert_awaited_once() - self.assertEqual(backend._home_sensor_position, 7859) - - async def test_home_rotor_waits_for_fresh_home_reference_after_runtime_attach(self): - backend = _make_backend(_FakeIO([])) - pre_home_status = backend._make_status(0x09, 4175, tachometer=0, home_position=4076) - stale_home_status = backend._make_status(0x89, 1145, tachometer=0, home_position=4076) - fresh_home_status = backend._make_status(0x89, 1202, tachometer=0, home_position=7900) - - backend._get_positions_and_tachometer = mock.AsyncMock(return_value=pre_home_status) - backend._motor_enable = mock.AsyncMock() - backend._send_safe = mock.AsyncMock() - backend._wait_for_idle = mock.AsyncMock(return_value=stale_home_status) - backend._wait_for_homed_status = mock.AsyncMock(return_value=fresh_home_status) - - await backend._home_rotor() - - backend._wait_for_idle.assert_awaited_once_with( - label="homing", - timeout=HOMING_TIMEOUT, - min_wait=1.0, - require_activity_from=pre_home_status, - activity_tolerance=POSITION_SETTLE_TOLERANCE, - ) - backend._wait_for_homed_status.assert_awaited_once() - self.assertEqual( - backend._wait_for_homed_status.await_args.kwargs["pre_home_status"], - pre_home_status, - ) - self.assertGreater(backend._wait_for_homed_status.await_args.kwargs["timeout"], 0) - self.assertEqual(backend._last_position, 1202) - self.assertEqual(backend._last_home_position, 7900) - self.assertEqual(backend._home_sensor_position, 7900) - - async def test_speed_wait_requires_measured_rpm_not_position_only(self): - backend = _make_backend( - _FakeIO([ - _status_packet(status=0x09, current_position=5000, tachometer=0, home_position=100), - ]) - ) - - with self.assertRaisesRegex(TimeoutError, "target speed"): - await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.01) - - async def test_speed_wait_accepts_measured_target_rpm(self): - tachometer = int(-(1000 * 0.95) / 14.69320388) - backend = _make_backend( - _FakeIO([ - _status_packet( - status=0x08, - current_position=1000, - tachometer=tachometer, - home_position=100, - ), - ]) - ) - - await backend._wait_for_speed_or_motion(rpm=1000, final_position=2000, timeout=0.5) - - async def test_open_door_waits_after_unlock_before_open_command(self): - backend = _make_backend(_FakeIO([])) - events = [] - - async def get_door_open() -> bool: - events.append("get_door_open") - return False - - async def get_door_locked() -> bool: - events.append("get_door_locked") - return True - - async def send_safe(cmd: bytes, **kwargs): - del kwargs - events.append(cmd.hex()) - return b"" - - async def wait_for_door(open_expected: bool, timeout: float) -> None: - events.append(("wait_for_door", open_expected, timeout)) - - async def sleep(seconds: float) -> None: - events.append(("sleep", seconds)) - - backend.get_door_open = get_door_open - backend.get_door_locked = get_door_locked - backend._send_safe = send_safe - backend._wait_for_door = wait_for_door - - with mock.patch("pylabrobot.centrifuge.vspin_backend.asyncio.sleep", sleep): - await backend.open_door() - - self.assertEqual( - events, - [ - "get_door_open", - "get_door_locked", - "aa022600042c", - ("sleep", 0.5), - "aa022600072f", - ("wait_for_door", True, 4.0), - ("sleep", 2.0), - ], - ) - - async def test_unlock_door_waits_after_unlock_command(self): - backend = _make_backend(_FakeIO([])) - events = [] - - async def get_door_locked() -> bool: - events.append("get_door_locked") - return True - - async def send_safe(cmd: bytes, **kwargs): - del kwargs - events.append(cmd.hex()) - return b"" - - async def sleep(seconds: float) -> None: - events.append(("sleep", seconds)) - - backend.get_door_locked = get_door_locked - backend._send_safe = send_safe - - with mock.patch("pylabrobot.centrifuge.vspin_backend.asyncio.sleep", sleep): - await backend.unlock_door() - - self.assertEqual( - events, - [ - "get_door_locked", - "aa022600042c", - ("sleep", 0.5), - ], - ) - - async def test_go_to_position_retries_after_wrong_idle_position(self): - backend = _make_backend(_FakeIO([])) - events = [] - wait_calls = 0 - - async def get_door_open() -> bool: - return False - - async def get_door_locked() -> bool: - return True - - async def prepare_bucket_motion() -> None: - events.append("prepare") - - async def motor_enable() -> None: - events.append("motor") - - async def send_safe(cmd: bytes, **kwargs): - del kwargs - if cmd.startswith(bytes.fromhex("aa01e6c8")): - events.append("profile") - elif cmd.startswith(bytes.fromhex("aa01d497")): - events.append("move") - return b"" - - async def wait_for_idle(**kwargs) -> None: - nonlocal wait_calls - del kwargs - events.append("wait") - wait_calls += 1 - if wait_calls == 1: - raise TimeoutError("wrong idle position") - - async def lock_bucket() -> None: - events.append("lock_bucket") - - async def open_door() -> None: - events.append("open_door") - - backend.get_door_open = get_door_open - backend.get_door_locked = get_door_locked - backend._prepare_bucket_motion = prepare_bucket_motion - backend._motor_enable = motor_enable - backend._send_safe = send_safe - backend._wait_for_idle = wait_for_idle - backend.lock_bucket = lock_bucket - backend.open_door = open_door - - with mock.patch("pylabrobot.centrifuge.vspin_backend.logger.warning"): - await backend.go_to_position(11842) - - self.assertEqual( - events, - [ - "prepare", - "motor", - "profile", - "move", - "wait", - "prepare", - "motor", - "profile", - "move", - "wait", - "lock_bucket", - "open_door", - ], - ) - - async def test_prepare_spin_motion_waits_for_spin_ready_io_state(self): - backend = _make_backend(_FakeIO([])) - commands = [] - statuses = [ - bytes.fromhex("0088090091"), - bytes.fromhex("0008080010"), - bytes.fromhex("0010080018"), - ] - - async def send_safe(cmd: bytes, **kwargs): - del kwargs - commands.append(cmd) - return b"" - - async def get_status() -> bytes: - commands.append(bytes.fromhex("aa020e10")) - return statuses.pop(0) - - backend._send_safe = send_safe - backend._get_status = get_status - - await backend._prepare_spin_motion() - - self.assertEqual( - commands, - [ - bytes.fromhex("aa0226000129"), - bytes.fromhex("aa020e10"), - bytes.fromhex("aa0226000028"), - bytes.fromhex("aa020e10"), - bytes.fromhex("aa020e10"), - ], - ) - - async def test_prepare_spin_motion_rejects_locked_bucket_before_spin(self): - backend = _make_backend(_FakeIO([])) - - async def send_safe(cmd: bytes, **kwargs): - del cmd, kwargs - return b"" - - async def get_status() -> bytes: - return bytes.fromhex("0088090091") - - backend._send_safe = send_safe - backend._get_status = get_status - - with self.assertRaisesRegex(TimeoutError, "spin ready"): - await backend._prepare_spin_motion() - - async def test_spin_rpm_sends_spin_command_immediately_after_spin_profile(self): - backend = _make_backend(_FakeIO([])) - events = [] - - async def get_door_open() -> bool: - return False - - async def get_door_locked() -> bool: - return True - - async def get_bucket_locked() -> bool: - return False - - async def get_position() -> int: - events.append("get_position") - return 12074 - - async def send_safe(cmd: bytes, **kwargs): - del kwargs - events.append(cmd.hex()) - return _status_packet(status=0x09, current_position=12074, tachometer=0) - - backend.get_door_open = get_door_open - backend.get_door_locked = get_door_locked - backend.get_bucket_locked = get_bucket_locked - backend.get_position = get_position - backend._prepare_spin_motion = mock.AsyncMock() - backend._motor_enable = mock.AsyncMock() - backend._send_safe = send_safe - backend._wait_for_speed_or_motion = mock.AsyncMock() - backend._hold_spin = mock.AsyncMock() - backend._wait_for_idle = mock.AsyncMock() - backend._home_rotor = mock.AsyncMock() - backend.lock_door = mock.AsyncMock(side_effect=AssertionError("unexpected lock door")) - backend.unlock_bucket = mock.AsyncMock(side_effect=AssertionError("unexpected unlock bucket")) - - await backend.spin_rpm(rpm=1500, duration=10) - - profile_index = events.index("aa01e60500640000000000fd00803e01000c") - self.assertEqual(events[profile_index + 1][:8], "aa01d497") - self.assertLess(events.index("get_position"), profile_index) - - async def test_spin_rpm_retries_when_target_speed_is_not_reached(self): - backend = _make_backend(_FakeIO([])) - events = [] - speed_waits = 0 - - async def get_door_open() -> bool: - return False - - async def get_door_locked() -> bool: - return True - - async def get_bucket_locked() -> bool: - return False - - async def get_position() -> int: - events.append("get_position") - return 12074 - - async def send_safe(cmd: bytes, **kwargs): - del kwargs - if cmd.startswith(bytes.fromhex("aa01e605")): - events.append("profile") - elif cmd.startswith(bytes.fromhex("aa01d497")): - events.append("spin") - return _status_packet(status=0x09, current_position=12074, tachometer=0) - - async def prepare_spin_motion() -> None: - events.append("prepare_spin") - - async def motor_enable() -> None: - events.append("motor") - - async def wait_for_speed_or_motion(**kwargs) -> None: - nonlocal speed_waits - del kwargs - events.append("wait_speed") - speed_waits += 1 - if speed_waits == 1: - raise TimeoutError("speed") - - async def hold_spin(duration: float) -> None: - events.append(("hold", duration)) - - async def send_deceleration(deceleration: float) -> None: - events.append(("decelerate", deceleration)) - - async def wait_for_idle(**kwargs) -> None: - events.append(("wait_idle", kwargs["label"])) - - backend.get_door_open = get_door_open - backend.get_door_locked = get_door_locked - backend.get_bucket_locked = get_bucket_locked - backend.get_position = get_position - backend._send_safe = send_safe - backend._prepare_spin_motion = prepare_spin_motion - backend._motor_enable = motor_enable - backend._wait_for_speed_or_motion = wait_for_speed_or_motion - backend._hold_spin = hold_spin - backend._send_deceleration = send_deceleration - backend._wait_for_idle = wait_for_idle - backend._home_rotor = mock.AsyncMock() - - with mock.patch("pylabrobot.centrifuge.vspin_backend.logger.warning"): - await backend.spin_rpm(rpm=1500, duration=10) - - self.assertEqual( - events, - [ - "prepare_spin", - "motor", - "get_position", - "profile", - "spin", - "wait_speed", - ("decelerate", 0.8), - ("wait_idle", "spin retry rundown"), - "prepare_spin", - "motor", - "get_position", - "profile", - "spin", - "wait_speed", - ("hold", 10.0), - ("decelerate", 0.8), - ("wait_idle", "spin rundown"), - ], - ) - - async def test_configuration_reasserts_control_lines_before_startup_baud(self): - io = _RecordingConfigIO() - backend = _make_backend(io) # type: ignore[arg-type] - - await backend.set_configuration_data() - - self.assertEqual( - io.calls, - [ - ("set_latency_timer", 16), - ("set_line_property", 8, 1, 0), - ("set_flowctrl", 0), - ("set_rts", True), - ("set_dtr", True), - ("set_baudrate", 19200), - ], - ) - - -class VSpinBackendSelectionTests(unittest.TestCase): - def test_factory_defaults_to_agilent_backend(self): - with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): - backend = create_vspin_backend() - - self.assertIsInstance(backend, VSpinBackend) - self.assertEqual(backend._model, "agilent") - - def test_factory_can_select_legacy_v11_model(self): - with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): - backend = create_vspin_backend(variant="v11") - - self.assertIsInstance(backend, VSpinBackend) - self.assertEqual(backend._model, "velocity11") From fd4db644ad9db3ec168b133f8555e9cee849ef31 Mon Sep 17 00:00:00 2001 From: lucas vogel Date: Sun, 5 Jul 2026 17:41:35 +0200 Subject: [PATCH 20/22] Change from fake I/O to Unit test mock --- pylabrobot/centrifuge/vspin_backend_tests.py | 23 +++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 5f38fbef22b..692685ae9b4 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -4,22 +4,7 @@ from pylabrobot.centrifuge.vspin_backend import VSpinBackend, _with_vspin_checksum -class _FakeIO: - def __init__(self, read_chunks): - self.read_chunks = list(read_chunks) - self.writes = [] - - async def read(self, num_bytes: int) -> bytes: - if self.read_chunks: - return self.read_chunks.pop(0) - return b"" - - async def write(self, data: bytes) -> int: - self.writes.append(data) - return len(data) - - -def _make_backend(io: _FakeIO) -> VSpinBackend: +def _make_backend(io: mock.Mock) -> VSpinBackend: backend = object.__new__(VSpinBackend) backend.io = io backend._command_set = "agilent" @@ -80,12 +65,14 @@ def test_with_vspin_checksum_repairs_final_byte(self): ) async def test_send_command_repairs_checksum_before_write(self): - io = _FakeIO([b"\r"]) + io = mock.Mock() + io.read = mock.AsyncMock(return_value=b"\r") + io.write = mock.AsyncMock(return_value=4) backend = _make_backend(io) await backend._send_command(bytes.fromhex("aa020e00")) - self.assertEqual(io.writes, [bytes.fromhex("aa020e10")]) + io.write.assert_awaited_once_with(bytes.fromhex("aa020e10")) def test_find_status_packet_scans_noise_and_validates_checksum(self): packet = _status_packet() From 769a481eb72400e0ed272ee5e7557dda22e801dc Mon Sep 17 00:00:00 2001 From: lucas vogel Date: Sun, 5 Jul 2026 18:16:16 +0200 Subject: [PATCH 21/22] Change name of lookup table function to _get_command_bytes --- pylabrobot/centrifuge/vspin_backend.py | 14 +++++++------- pylabrobot/centrifuge/vspin_backend_tests.py | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/centrifuge/vspin_backend.py index c05a356f9b6..f28b1bf9ce5 100644 --- a/pylabrobot/centrifuge/vspin_backend.py +++ b/pylabrobot/centrifuge/vspin_backend.py @@ -370,7 +370,7 @@ async def stop(self): await self.configure_and_initialize() await self.io.stop() - def _command(self, name: str) -> bytes: + def _get_command_bytes(self, name: str) -> bytes: return _VSPIN_COMMANDS[self._command_set][name] class _StatusPositionTachometer(ctypes.LittleEndianStructure): @@ -518,7 +518,7 @@ async def initialize(self): async def open_door(self): if await self.get_door_open(): return - await self._send_command(self._command("open_door")) # same as unlock door on new firmware + await self._send_command(self._get_command_bytes("open_door")) # same as unlock door on new firmware # we can't tell when the door is fully open, so we just wait a bit await asyncio.sleep(4) @@ -526,7 +526,7 @@ async def open_door(self): async def close_door(self): if not (await self.get_door_open()): return - await self._send_command(self._command("close_door")) # same as unlock door on new firmware + await self._send_command(self._get_command_bytes("close_door")) # same as unlock door on new firmware # we can't tell when the door is fully closed, so we just wait a bit await asyncio.sleep(2) @@ -535,22 +535,22 @@ async def lock_door(self): raise RuntimeError("Cannot lock door while it is open.") if await self.get_door_locked(): return - await self._send_command(self._command("lock_door")) + await self._send_command(self._get_command_bytes("lock_door")) async def unlock_door(self): if not await self.get_door_locked(): return - await self._send_command(self._command("unlock_door")) # same as close door + await self._send_command(self._get_command_bytes("unlock_door")) # same as close door async def lock_bucket(self): if await self.get_bucket_locked(): return - await self._send_command(self._command("lock_bucket")) + await self._send_command(self._get_command_bytes("lock_bucket")) async def unlock_bucket(self): if not await self.get_bucket_locked(): return - await self._send_command(self._command("unlock_bucket")) # same as open door on new firmware + await self._send_command(self._get_command_bytes("unlock_bucket")) # same as open door on new firmware async def go_to_bucket1(self): await self.go_to_position(await self.get_bucket_1_position()) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 692685ae9b4..2cb7bc8d776 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -35,18 +35,18 @@ def test_default_command_set_is_agilent(self): backend = VSpinBackend() self.assertEqual(backend._command_set, "agilent") - self.assertEqual(backend._command("open_door"), bytes.fromhex("aa022600062e")) - self.assertEqual(backend._command("lock_bucket"), bytes.fromhex("aa022600072f")) + self.assertEqual(backend._get_command_bytes("open_door"), bytes.fromhex("aa022600062e")) + self.assertEqual(backend._get_command_bytes("lock_bucket"), bytes.fromhex("aa022600072f")) def test_old_firmware_command_set_uses_legacy_pneumatic_commands(self): with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): backend = VSpinBackend(command_set="old_firmware") self.assertEqual(backend._command_set, "old_firmware") - self.assertEqual(backend._command("open_door"), bytes.fromhex("aa022600072f")) - self.assertEqual(backend._command("close_door"), bytes.fromhex("aa022600052d")) - self.assertEqual(backend._command("lock_bucket"), bytes.fromhex("aa0226000129")) - self.assertEqual(backend._command("unlock_bucket"), bytes.fromhex("aa0226200048")) + self.assertEqual(backend._get_command_bytes("open_door"), bytes.fromhex("aa022600072f")) + self.assertEqual(backend._get_command_bytes("close_door"), bytes.fromhex("aa022600052d")) + self.assertEqual(backend._get_command_bytes("lock_bucket"), bytes.fromhex("aa0226000129")) + self.assertEqual(backend._get_command_bytes("unlock_bucket"), bytes.fromhex("aa0226200048")) def test_velocity11_label_is_not_a_command_set(self): with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): From 6b7e9077a9e1b083d22a79560207dc4cc7cd5067 Mon Sep 17 00:00:00 2001 From: lucas vogel Date: Sun, 5 Jul 2026 19:05:55 +0200 Subject: [PATCH 22/22] remove unnecessary stuff, just test the parsing --- pylabrobot/centrifuge/vspin_backend_tests.py | 29 +++++--------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/pylabrobot/centrifuge/vspin_backend_tests.py b/pylabrobot/centrifuge/vspin_backend_tests.py index 2cb7bc8d776..b8547c50a13 100644 --- a/pylabrobot/centrifuge/vspin_backend_tests.py +++ b/pylabrobot/centrifuge/vspin_backend_tests.py @@ -4,6 +4,10 @@ from pylabrobot.centrifuge.vspin_backend import VSpinBackend, _with_vspin_checksum +# status=0x11, current_position=12070, tachometer=-10, home_position=6733, checksum=0x29 +_STATUS_PACKET = bytes.fromhex("11262f00004ff6ff184d1a000029") + + def _make_backend(io: mock.Mock) -> VSpinBackend: backend = object.__new__(VSpinBackend) backend.io = io @@ -12,23 +16,6 @@ def _make_backend(io: mock.Mock) -> VSpinBackend: return backend -def _status_packet( - status: int = 0x11, - current_position: int = 12070, - tachometer: int = -10, - home_position: int = 6733, -) -> bytes: - packet = ( - bytes([status]) - + current_position.to_bytes(4, "little") - + b"\x4f" - + tachometer.to_bytes(2, "little", signed=True) - + b"\x18" - + home_position.to_bytes(4, "little") - ) - return packet + bytes([sum(packet) & 0xFF]) - - class VSpinCommandSetTests(unittest.IsolatedAsyncioTestCase): def test_default_command_set_is_agilent(self): with mock.patch("pylabrobot.centrifuge.vspin_backend.FTDI"): @@ -74,10 +61,8 @@ async def test_send_command_repairs_checksum_before_write(self): io.write.assert_awaited_once_with(bytes.fromhex("aa020e10")) - def test_find_status_packet_scans_noise_and_validates_checksum(self): - packet = _status_packet() - - parsed = VSpinBackend._find_status_packet(b"\x00\xff" + packet + b"\x00") + def test_find_status_packet_parses_status_packet(self): + parsed = VSpinBackend._find_status_packet(_STATUS_PACKET) assert parsed is not None self.assertEqual(parsed.status, 0x11) @@ -86,7 +71,7 @@ def test_find_status_packet_scans_noise_and_validates_checksum(self): self.assertEqual(parsed.home_position, 6733) def test_find_status_packet_rejects_bad_checksum(self): - packet = bytearray(_status_packet()) + packet = bytearray(_STATUS_PACKET) packet[-1] ^= 0xFF self.assertIsNone(VSpinBackend._find_status_packet(bytes(packet)))